Commit Graph

6236 Commits

Author SHA1 Message Date
Félix Malfait 2e099c91e1 fix(domains): show custom domain DNS records and activation status without a page reload (#22037)
## Problem

Setting up a custom domain had two confusing UX issues, both caused by
local state not being refreshed after the relevant mutation:

1. **DNS records didn't appear after saving.** After hitting save you
got the green "Custom domain updated" snackbar, but the "Domain Setup"
section (the Cloudflare/DNS records to configure) stayed empty. You had
to leave the page and come back for the records to show up.
2. **The "Custom Domain" card stayed "Inactive"** even after the DNS
records validated as "Success". Only a full page reload flipped it to
"Active".

## Root cause

**Issue 1 — stale closure.** In `useSettingsCustomDomain.handleSave`,
the `updateWorkspace` `onCompleted` callback called
`setCurrentWorkspace({ ...currentWorkspace, customDomain })` and then
`checkCustomDomainRecords()`. But `checkCustomDomainRecords` guarded on
the closed-over `currentWorkspace.customDomain`, which was still `null`
at that render. The `setCurrentWorkspace` call doesn't synchronously
update that captured value, so the guard returned early and the records
were never fetched. Remounting the page (navigate away/back) ran the
on-mount effect with a fresh workspace, which is why the trip "fixed"
it.

**Issue 2 — `isCustomDomainEnabled` never refreshed locally.** The
Active/Inactive badge is driven by
`currentWorkspace.isCustomDomainEnabled`. The backend flips this flag
inside `checkCustomDomainValidRecords`
(`custom-domain-manager.service.ts`), but the mutation didn't return it,
so the local `currentWorkspaceState` stayed stale until a full reload
re-ran the bootstrap query. The green "Success" DNS rows read from a
different source (`record.status`), which is why the rows and the badge
disagreed.

## Changes

**Issue 1**
- `checkCustomDomainRecords` now accepts the domain explicitly
(defaulting to the workspace value), so the freshly-saved domain can be
passed straight from `handleSave` instead of relying on the stale
closure. No new `useEffect` introduced.
- Fixed the Reload button so it no longer passes its click event as the
domain argument.

**Issue 2**
- Added a nullable `isCustomDomainEnabled` field to the
`DomainValidRecords` GraphQL type, populated only by the custom-domain
check (the shared public-domain flow leaves it null, so it's backward
compatible).
- The frontend now writes that value back into `currentWorkspaceState`
when the check completes, using a **functional** Jotai update so a
concurrent `customDomain` update is never clobbered. The badge flips to
"Active" as soon as validation passes — on mount, on Reload, and right
after save.

I deliberately kept this targeted rather than introducing real-time
workspace sync: `isCustomDomainEnabled` only changes server-side during
the on-demand DNS check (mount/Reload/cron), so returning it from that
mutation is sufficient and far lower risk.

## Notes
- `packages/twenty-front/src/generated-metadata/graphql.ts` was updated
to match what `graphql:generate` produces for the new schema field
(codegen requires a running backend, which isn't available in this
environment). Worth re-running codegen in CI to confirm it's
byte-identical.
- No existing unit or integration tests reference these paths.

## Test plan
- [ ] Set a custom domain → DNS records appear immediately (no
navigation needed).
- [ ] Once DNS validates, the "Custom Domain" card flips to "Active"
without a reload.
- [ ] Reload button still refreshes records.
- [ ] Public domain validation flow is unaffected.

https://claude.ai/code/session_01BB6C6bpPZMUbMzKSCydEaj

---
_Generated by [Claude
Code](https://claude.ai/code/session_01BB6C6bpPZMUbMzKSCydEaj)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22037?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. -->
2026-06-23 18:02:44 +02:00
Félix Malfait f24be8eacb fix: keep AI chat open when opening a record full-page (e.g. workflows) (#22036)
## Problem

When the AI chat is open in the side panel and you click a workflow from
the Workflows list, the AI chat closes as you navigate to the workflow
page. Navigating between other index/list pages, or to settings, keeps
the chat open — only opening a record full-page closes it.

## Root cause

`useOpenRecordFromIndexView` unconditionally calls
`closeSidePanelMenu()` before navigating to a full-page record:

```ts
} else {
  closeSidePanelMenu();
  navigate(AppPath.RecordShowPage, { ... });
}
```

Workflows (and other objects excluded by `canOpenObjectInSidePanel` —
`workflow`, `workflowVersion`, `dashboard`) can't open in the side
panel, so they *always* take this branch and close the panel, including
the AI chat.

This is inconsistent with `PageChangeEffect`, which already lets the AI
chat survive navigation by exempting `SidePanelPages.AskAI`. That
exemption is why navigating between index pages or to settings doesn't
close the chat.

## Fix

Skip the close when the side panel is showing the AI chat, mirroring the
exemption already used in `PageChangeEffect`. Any other side panel page
still closes as before.

## Testing

- `nx lint:diff-with-main twenty-front` (file lints clean)
- `nx typecheck twenty-front` passes

https://claude.ai/code/session_01LtBBAxjn32FQVduyAi6B37

---
_Generated by [Claude
Code](https://claude.ai/code/session_01LtBBAxjn32FQVduyAi6B37)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22036?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. -->
2026-06-23 18:01:51 +02:00
Raphaël Bosi 766d90af7e Remove framer-motion from twenty-ui (#22021)
## What

Removes the `framer-motion` dependency from `twenty-ui` and replaces
every usage with pure CSS animations, reaching for Base UI primitives
where one fits:

- **Collapse/expand** (`AnimatedEaseInOut`,
`AnimatedExpandableContainer`): rebuilt on Base UI `Collapsible`
(CSS-animated `--collapsible-panel-height/width` + transition states).
Public props unchanged, so the ~28 call sites are untouched.
- **ProgressBar**: rebuilt on Base UI `Progress` (proper
`role`/`aria-valuenow`). The snackbar auto-dismiss countdown now uses a
CSS keyframe + `animation-play-state` (pause on hover), removing a
per-frame React re-render; `useProgressAnimation` is deleted.
- The remaining `Animated*` components, the circular spinner, checkmark,
and the placeholder pointer parallax move to plain CSS (SCSS modules +
the `duration()` helper + theme tokens).
- Deletes 3 unused components (`AnimatedTranslation`,
`AnimatedTextWord`, `AnimatedFadeOut`).

## Why

`twenty-ui` is a publicly published library with a size budget, so
dropping framer-motion shrinks what consumers ship. `twenty-front` keeps
its own framer-motion; that is out of scope here.

## Notes for reviewers

- A few `twenty-ui` components received framer props from `twenty-front`
call sites; those were migrated (e.g. `AnimatedLightIconButton` gained a
CSS `rotate` prop, and the `EMPTY_PLACEHOLDER_TRANSITION_PROPS` spreads
were removed).
- Behavior change: Base UI `Collapsible` animates only on open/close
transitions, so the old "animate in on first mount while already open"
case no longer plays (the `initial` prop is kept for API compatibility).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22021?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. -->
2026-06-23 17:43:08 +02:00
Johnny Martin d4e4e2612b feat(front): render Instagram URLs as @handles in link fields (#21642)
LinkedIn and X links already show a readable handle in Twenty's link
fields. Instagram doesn't — it just shows `instagram.com`, which isn't
much help when you're scanning a record.

This adds the same handling for Instagram. `instagram.com/ptcrash` now
shows as `@ptcrash`, in tables, on record pages, and in the edit menu.
Post and reel links (`/p/...`, `/reel/...`) have no handle, so they fall
back to `Instagram`.

How it works:
- `Instagram` added to the `LinkType` enum
- `checkUrlType` detects `instagram.com`
- `getDisplayValueByUrlType` pulls the handle and prefixes `@`
- a shared `isSocialLinkType` helper keeps the three display components
in sync

Tested with unit tests for both helpers, the updated story, and manually
against a record whose Instagram field is
`http://instagram.com/ptcrash`.

Closes #21644

Co-authored-by: Johnny Martin <ptcrash@users.noreply.github.com>
2026-06-23 17:25:50 +02:00
Félix Malfait 855664daa2 feat(timeline): activity kind registry (Layer A) (#21950)
## What & why

The timeline-activity system's contract is a magic `name` string
(`"company.updated"`, `"linked-note.created"`, `"message.linked"`)
decoded by `String.split('.')` in **four** different frontend spots and
produced by a hardcoded `if`-ladder + two listeners. It is not
extensible and it already harbored a latent bug.

This PR replaces that stringly-typed protocol with an explicit,
persisted **`kind`** contract consumed through registries on both ends.
Adding a new timeline activity type becomes: add a producer + register a
presenter — no edits to a central switch.

This is **Layer A** of a larger plan (see
`packages/twenty-server/docs/TIMELINE_ACTIVITIES_REFACTOR.md` and
`TIMELINE_ACTIVITIES_PR_A.md`). Layer B (timeline projection /
"inheritance") and Layer C (user-defined aggregation rules) are
intentionally **out of scope** here.

## 🐛 Bug fixed along the way

`calendar-event-participant.listener.ts` was writing calendar-event
timeline rows with `name: 'message.linked'` (copy-paste from the message
listener). It rendered "correctly" only by luck — the frontend routed on
`linkedObjectMetadataId → nameSingular`, never on `name`. This PR fixes
it at the source (`calendarEvent.linked` / `kind:
'linkedCalendarEvent'`), and the shared resolver also corrects
historical rows that carry the wrong `name`.

## Changes

**`twenty-shared`** — new `timeline` module
- `TimelineActivityKind` (`recordChange | linkedNote | linkedTask |
linkedMessage | linkedCalendarEvent | linkedRecord`) +
`resolveTimelineActivityDescriptor`, the **single** place that decodes
an activity into `{ kind, action }`. Reads the persisted `kind` when
present and falls back to legacy `name`/`linkedObjectMetadataId` parsing
(back-compat shim). Unit-tested (20 cases).

**`twenty-server`**
- Persist a nullable `kind` field on the `timelineActivity` standard
object (entity shape + field-metadata builder + universalIdentifier).
- Producers (`timeline-activity.service.ts`, the two participant
listeners) set `kind` explicitly; dev seeder populates it.
- Fix the `calendarEvent.linked` mislabel.

**`twenty-front`**
- Static `TIMELINE_ACTIVITY_PRESENTERS` registry replaces the render
`switch`, the icon `if`-chain, the diff-validation name-parsing, and the
`name.match(/note|task/i)` title-prefetch hack.
- New `EventRowGenericLinked` so an unknown linked object type renders a
real "linked a {object}" row instead of falling through to the wrong
(main-object) renderer.

## Migration / compatibility
- The `kind` column on this **workspace** standard object is created by
the normal workspace metadata sync — no hand-written migration. It is
**nullable**, so pre-upgrade rows degrade gracefully through the
resolver shim (they resolve correctly from `linkedObjectMetadataId` +
`name`). An optional backfill workspace command could populate `kind` on
old rows later; not required for correctness.
- No GraphQL breaking change — `kind` is additive, `name` is retained
for display/search.

## Test plan
- `twenty-shared` unit tests (resolver) 
- `typecheck` + `lint:diff-with-main` green on `twenty-front`,
`twenty-server`, `twenty-shared` 
- Reset + reseed a workspace: `kind` is populated for all seeded rows
(recordChange / linkedMessage / linkedNote / linkedTask /
linkedCalendarEvent) with no nulls 
- Manual end-to-end verification via Playwright on person / company
record timelines — screenshots in a follow-up comment.

Screenshots attesting the rendering (incl. the calendar fix) are posted
as a comment below.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01YRueWMo4UyaX2em8R2cdio)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21950?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. -->
2026-06-23 16:59:09 +02:00
Paul Rastoin e9d5d71cd3 Wire up search field metadata (#21964)
## Part 1 - Exact scope of the current PR (#21964)

close https://github.com/twentyhq/core-team-issues/issues/2586

This PR introduces `searchFieldMetadata` as a first-class flat metadata
entity and migrates the existing search surface onto it, with **no
change to which records are searchable** (ISO with `main`).

In scope (what the PR does):
- New flat entity `searchFieldMetadata` (universalIdentifier,
applicationId, **`position`**, maps, conversions), registered in the
central flat-entity constants and the migration build orchestrator.
- `searchVector.asExpression` is **derived server-side** from
`searchFieldMetadata` rows (validated by `isSafeTsVectorExpression`);
never trusted from client input.
- **Derivation order is deterministic, driven by each row's `position`**
([compute-search-vector-as-expression-from-search-field-metadatas.util.ts](packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/utils/compute-search-vector-as-expression-from-search-field-metadatas.util.ts)),
replacing the previous non-deterministic `(createdAt, id)` sort. That
sort collapsed to random UUIDs for standard fields (same `createdAt`),
so any rename/relabel rewrote the `STORED` generated column to a
logically-identical-but-textually-different expression and produced a
permanent per-workspace diff vs the standard definition. Ordering now
equals provisioning order; ties break on `universalIdentifier`.
- Provisioning at object creation mirrors the existing surface exactly
**and seeds `position`**:
- custom objects -> the `name` field only, at `position: 0`
([build-default-search-field-metadatas-for-custom-object.util.ts](packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-search-field-metadatas-for-custom-object.util.ts))
- standard objects -> their curated `SEARCH_FIELDS_FOR_*` sets,
`position` = the curated index
- Backfill (instance + workspace commands in `2-16`) provisions rows for
existing workspaces with the same surface **and the same positions**
(standard from the curated standard maps, custom `name` = `0`), scoped
to the workspace's own custom application
([build-search-field-metadata-backfill-operations.util.ts](packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util.ts)).
The `position` column is added in the same `2-16` fast instance command
as `universalIdentifier`/`applicationId`.
- Field rename of an already-indexed field recomputes `asExpression`
(positions preserved, so order is stable)
([recompute-search-vector-on-field-rename.util.ts](packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/recompute-search-vector-on-field-rename.util.ts)).
- Field delete drops the matching row(s) and recomputes; remaining rows
keep their relative order (no renumber)
([from-delete-field-input-to-flat-field-metadatas-to-delete.util.ts](packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-delete-field-input-to-flat-field-metadatas-to-delete.util.ts)).
- Object relabel is **additive** and ISO/regression-fix only: it indexes
the new label identifier **appended last (`position = max(existing) +
1`)** without dropping `name`
([recompute-search-vector-on-label-identifier-update.util.ts](packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/recompute-search-vector-on-label-identifier-update.util.ts)).
This is a deliberate, temporary bridge.

Explicitly OUT of scope (deferred):
- No API to edit `searchFieldMetadata` (no user-facing search-field
configuration, including `position` — it is internal and only written by
provisioning/backfill/recompute).
- No auto-indexing of arbitrary searchable fields. Creating a custom
TEXT/EMAILS/etc. field does NOT add it to search (the
`computeSearchFieldMetadataCreationForFields` behavior was removed in
`e6820ad`).
- No field-type-transition handling (field type is immutable - not in
`FLAT_FIELD_METADATA_EDITABLE_PROPERTIES`, so that path was dead code).
- No `position` validation (uniqueness/range) and no multi-vector /
per-field `weight` config — deferred to the configurable-search
follow-up (#1428).

Net: `searchFieldMetadata` becomes the source of truth for the *same*
surface as `main`. The only intentional divergences from `main` are
"relabel preserves `name`" (additive) and the deterministic
`position`-ordered `asExpression` (a correctness/perf fix that is
byte-identical to provisioning order, so it does not change the
searchable surface).

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2026-06-23 16:27:13 +02:00
Parship Chowdhury c18787350f fix: month and year dropdowns in settings logs date picker (#21529)
### Summary
- Fixes #21514 and Issue 2
- **Issue 1**: when opening the calendar and choosing a month or year,
those lists could appear underneath the calendar, making them impossible
to see and use. (Issue #21514)
- **Issue 2**: after opening the calendar icon menu, clicking the month
or year controls don't work, so you couldn’t actually change the month
or year.

Before:
<img width="355" height="434"
alt="607363028-0d3a302e-9dba-4d9a-b354-ad7cbcd1fba5"
src="https://github.com/user-attachments/assets/b1c357d1-a7cf-4572-8737-721cf4e2597a"
/>

After:


https://github.com/user-attachments/assets/ffc26447-ff34-4f11-a3b4-4c329e446ec4



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21529?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. -->

---------

Signed-off-by: Parship Chowdhury <parshipchowdhury@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-23 11:40:27 +02:00
avonian 47b48d83f7 fix(front): include isUIEditable/isRemote in CreateOneObjectMetadataItem so new objects aren't read-only until refresh (#21796)
## Problem

After creating a custom object in **Settings → Data Model**, the new
object's **"New Field"** (and **"Add relation"**) buttons are missing.
They only appear after a hard page refresh.

## Root cause

`CreateOneObjectMetadataItem`
(`packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts`)
selected only a subset of object-level fields and omitted
`isUIEditable`, `isRemote`, `isSystem`, `isUICreatable`,
`universalIdentifier`, `shortcut`, and `duplicateCriteria` — all of
which are present in the shared `ObjectMetadataFields` fragment used by
the bootstrap query.

`useCreateOneObjectMetadataItem` writes the mutation response into the
metadata store via `addToDraft`. Because the mutation resolves *after*
the SSE create event and `addToDraft` replaces entries by `id`, the
reduced mutation response overwrites the fuller record that arrived over
SSE. The stored object then has `isUIEditable === undefined`, so
`isObjectMetadataReadOnly` returns `true` (`!undefined`), and
`ObjectFields` hides the action buttons via its `{!readonly && …}`
guard.

A hard refresh "fixes" it only because the bootstrap query repopulates
the store from `ObjectMetadataFields`, which includes the missing
fields.

## Fix

Add the missing object-level fields to the `CreateOneObjectMetadataItem`
selection so a newly created object matches the bootstrap shape, and
regenerate the metadata GraphQL types. No other code changes required.

## How to test

1. Go to **Settings → Data Model** and create a new custom object.
2. Open the new object's **Fields** tab.
3.  The **"New Field"** button is visible immediately — no refresh
needed.

Before this change, the button was hidden until a manual refresh.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21796?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. -->
2026-06-23 10:46:16 +02:00
Félix Malfait 0e6d96bb5e fix(workflow): stop trigger/action filter Conditions from flashing on edit (#21952)
## Problem

Adding a condition to a database-event **trigger** (the new Conditions
section), the **Filter** action, or the **If/Else** action causes the
just-added condition to flash out and back in.

## Root cause

`WorkflowEditActionFilterBodyEffect` seeds the builder's local jotai
atoms from the persisted `defaultValue` through an effect that
**resynced whenever the live atoms differed from `defaultValue`** (the
atoms were in the effect deps and the equality check compared atoms vs
`defaultValue`).

A local edit writes the atoms **synchronously**, then persists through
an **async** mutation — and for an *active* workflow that mutation first
creates a draft version over the network. During that window the atoms
are ahead of the still-stale `defaultValue`, so the effect treated it as
"out of sync" and overwrote the edit back to the stale value, then wrote
it again once the save landed. That round-trip is the flash.

The resync existed for a real reason: the atoms are module-cached per
`instanceId` and persist across mounts, and the trigger shares a single
**constant** `instanceId` (`'trigger'`), so a previous trigger's filters
must be overwritten when a different one is opened. (This is also why
the `?? { stepFilterGroups: [], stepFilters: [] }` fallback was added in
#21868 — to reset builder state deterministically between trigger
edits.) So a naive "init-once" fix would reintroduce that stale-state
leak.

## Fix

Resync from `defaultValue` **only when `defaultValue` itself changes**,
tracked via the last-synced value in `useState` (not the live atoms).
This:

- never clobbers an in-flight local edit → no flash;
- still re-seeds when switching the trigger/action being edited → no
stale-state leak;
- preserves reflecting genuine external `defaultValue` changes.

The `hasInitialized*` flags are no longer needed and are removed (along
with the now-unused `stepId` prop on the effect).

## Tests

Adds a regression test covering: seeding from `defaultValue` on mount,
the **no-clobber-while-stale** invariant (the flash), and resync on a
genuine `defaultValue` change. Verified the no-clobber test **fails**
against the old "resync against live atoms" behavior and passes with the
fix.

## Verification

- `nx typecheck twenty-front` 
- `nx lint:diff-with-main twenty-front`  (0 warnings / 0 errors)
- New unit test: 3 passing 

## Known residual / follow-up

On an *active* workflow, the first edit creates a draft version over the
network; making a second edit before that round-trip completes leaves a
narrow window where the optimistic echo of the first value could
momentarily win. Far narrower than the current flash-on-every-edit.
Eliminating it entirely (and resolving the still-open HIGH-severity
"constant `instanceId`" review flag from #21868) would mean giving the
trigger a unique `instanceId` per workflow version + a React `key` to
reset on remount — proposed as a separate, scoped follow-up.

https://claude.ai/code/session_01XnjtzFepMJX2VFwnQVcQbV

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XnjtzFepMJX2VFwnQVcQbV)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21952?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. -->
2026-06-23 10:44:56 +02:00
neo773 9e31ffdf68 feat(messaging): webhook push sync for Gmail, Calendar and Microsoft (#21970)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21970?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. -->
2026-06-23 14:04:16 +05:30
Priyanshu Bartwal 6e7d8ef96c [Twenty-front]: Record table Header drag and drop functionality (#21304)
Closes #21303 and
https://github.com/twentyhq/core-team-issues/issues/151




https://github.com/user-attachments/assets/45cee1be-464f-467e-a1c0-cf5354ff87db

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-23 08:37:04 +02:00
Félix Malfait 46642c81c9 fix(front): unblock email verification on the central domain (blank modal) (#21980)
## Problem

After clicking the email-verification link on the central domain (e.g.
`app.twenty.com/verify-email?...`), a new user is left staring at a
**blank white auth modal** and onboarding never continues. The email is
actually verified — the user is just never moved off the verify-email
page.

## Root cause

`VerifyEmailEffect` (mounted on `/verify-email`) handles the
central/workspace‑agnostic domain like this:

```tsx
if (!isOnAWorkspace) {
  await verifyEmailAndGetWorkspaceAgnosticToken(emailVerificationToken, email);
  return enqueueSuccessSnackBar(successSnackbarParams);
}
```

It renders nothing of its own in this branch (`return <></>`) and relies
entirely on the auth hook to navigate.

The onboarding workspace-creation refactor (**#21641** "Let users pick
their workspace subdomain during sign-up", refined by **#21723**)
changed `navigateAfterMultiWorkspaceSignInUp`:

- **Before:** a user with `0` workspaces was sent through
`createWorkspace()`, which created the workspace and **redirected to the
workspace subdomain** — navigating away from `/verify-email`.
- **After:** for multi-workspace it now only does
`setSignInUpStep(SignInUpStep.WorkspaceCreation)` (the new
name/subdomain/logo form) — **no navigation**.

`signInUpStepState` is read **only by the `SignInUp` page**
(`/sign-in-up`), which renders `SignInUpWorkspaceCreationForm` for that
step. But the user is on `/verify-email`, whose route renders only
`VerifyEmailEffect` — which knows nothing about the step state and
returns an empty fragment. Nothing bridges the gap
(`usePageChangeEffectNavigateLocation` also won't redirect, because
`/verify-email` is whitelisted in `ONGOING_USER_CREATION_PATHS`), so the
user is stuck on an empty modal.

### Scope of the breakage
- **Broken:** new user, multi-workspace instance (Twenty Cloud central
domain), email verification enabled, signing up to create a workspace
(`0` workspaces). The `2+`-workspaces case (`WorkspaceSelection`) is the
same.
- **Not affected:** the single existing-workspace case (still does a
real `redirectToWorkspaceDomain`), the workspace-subdomain verification
path (`verifyEmailAndGetLoginToken` → `verifyLoginToken`), and
single-workspace self-host.

## Fix

After a successful workspace-agnostic verification, hand off to the
`SignInUp` page so it mounts and renders whatever step the hook just
set:

```tsx
if (!isOnAWorkspace) {
  await verifyEmailAndGetWorkspaceAgnosticToken(emailVerificationToken, email);
  enqueueSuccessSnackBar(successSnackbarParams);
  return navigate(AppPath.SignInUp);
}
```

This is intentionally scoped to `VerifyEmailEffect` (the only entry
point that lives on a route which doesn't host the sign-in-up step UI).
The in-app sign-in/sign-up callers of
`navigateAfterMultiWorkspaceSignInUp` are already on `/sign-in-up`, so
they're untouched — keeping their query params (invite tokens, billing
checkout, returnToPath) intact. For the single existing-workspace edge
case, the hook's redirect still wins.

## Testing

- New `VerifyEmailEffect.test.tsx`:
- central-domain success → navigates to `AppPath.SignInUp` + shows the
success snackbar;
- failure → does **not** hand off to `SignInUp` (error state is shown);
- workspace subdomain → workspace-scoped path is untouched (no
workspace-agnostic call, no `SignInUp` hand-off).
- `nx typecheck twenty-front` , `oxlint --type-aware` + `oxfmt` on
changed files .

https://claude.ai/code/session_017oVwW12hC42RdCgSKK8dFP

---
_Generated by [Claude
Code](https://claude.ai/code/session_017oVwW12hC42RdCgSKK8dFP)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21980?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. -->
2026-06-23 08:19:33 +02:00
neo773 242b989c0e fix(emails): stop reply composer infinite re-render (#21935)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21935?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. -->
2026-06-23 00:57:54 +02:00
mfamularopsyc d2083e7a1b Set OpenAI Responses store false for AI chat and agents (#20888)
## Summary

This PR sets `openai.store = false` for Twenty's `@ai-sdk/openai` AI
calls.

This follows the approach discussed in #20877: instead of adding a new
Twenty-specific Zero Data Retention config variable, OpenAI Responses
calls no longer rely on OpenAI-stored response/item references. This
should help Zero Data Retention organizations and may also avoid stale
persisted-item replay errors for non-ZDR OpenAI users.

Changes included:

- Adds a shared OpenAI provider-options helper that merges `openai.store
= false` for `@ai-sdk/openai` models.
- Applies the helper to AI chat `streamText` calls.
- Applies the helper to workflow/agent `generateText` calls.
- Preserves OpenAI encrypted reasoning metadata through DB/UI message
mappers so reasoning context can be replayed without stored OpenAI item
references.
- Does not add a new env/config variable.

Related to issue #20877.

## Behavior / Tradeoffs

This changes OpenAI Responses behavior for all Twenty OpenAI users, not
only ZDR users.

The intended benefit is that Twenty no longer depends on OpenAI-stored
response/item references. The main tradeoff is reduced provider-side
item-reference reuse for non-ZDR OpenAI users.

To reduce the impact for reasoning models, this PR preserves
`providerMetadata.openai.reasoningEncryptedContent` through message
persistence/replay so reasoning context can still be provided without
stored OpenAI item references.

## Tests

- Focused server Jest tests for OpenAI provider-options merging and
reasoning metadata mapping.
- Focused frontend Jest test for reasoning metadata mapping.
- `oxlint` and `oxfmt --check` on changed files.
- `git diff --check`.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-06-22 17:26:02 +00:00
Raphaël Bosi 5f22908588 Decouple twenty-ui Avatar from app server-URL config (#21968)
Makes `twenty-ui`'s `Avatar` render the `avatarUrl` it receives instead
of building it from `window._env_`/`window.location` at module load, so
the library no longer depends on the app environment. URL resolution
moves to `twenty-front` via a `getAbsoluteImageUrl` helper applied at
the call sites.

Part of making twenty-ui a standalone library.
2026-06-22 18:38:00 +02:00
Marie 96a2987610 Fix/sanitize chart filters on save (#21958)
# Fix: sanitize chart filters referencing deactivated/deleted fields

## Summary

When a field used in a chart (graph) widget filter was later
**deactivated or deleted**, saving the page layout failed with a backend
error such as:

> Chart "...": One of the chart filters uses "...", but it was deleted.
Please remove or replace this filter rule.

This happened even after the user tried to remove the offending filter
rule, because the invalid filter could still end up in the saved
configuration. This PR makes invalid chart filters get cleaned up
reliably — both as the user edits filters and, as a safety net, at save
time.

## Root causes

- **Edit-time persistence kept invalid filters.** `handleFiltersUpdate`
persisted the current filter state to the page layout draft without
sanitizing it against the object's active fields. An invalid filter
(referencing a deactivated/deleted field) was re-saved on every update,
blocking the configuration from being accepted.
- **Save never enforced the cleanup.** `useSavePageLayout` serialized
the draft as-is. The "filters referencing deactivated/deleted fields
will be automatically removed on save" promise shown in the warning
banner was only honored reactively (when the filter panel was actively
edited), never at the actual save boundary. A chart whose filter panel
wasn't touched kept its stale invalid filter in the payload.
- **Query time treated inactive fields as valid.**
`useGraphWidgetQueryCommon` considered all fields (including inactive
ones) valid, so deactivated-field filters were never dropped when
running the chart query.

## Changes

### Edit-time (keeps draft and UI in sync as you edit)
- `ChartFiltersSettings` — sanitize filters in `handleFiltersUpdate`
before writing to the draft, dropping any filter whose `fieldMetadataId`
is not in the active-fields set.
- `dropChartRecordFiltersWithDeletedFields` — enhanced to also clean up
filter groups left orphaned once invalid filters are removed
(iteratively removing empty groups and re-parenting checks).
- `useGraphWidgetQueryCommon` — restrict valid field IDs to `isActive`
fields so deactivated-field filters are silently dropped at query
execution.
- `ChartFiltersDeletedFieldsWarning` — updated copy to mention both
deactivated and deleted fields.

### Save-time safety net (guarantees no invalid filter is ever
persisted)
- New `sanitizeChartFiltersInPageLayoutDraft` util — walks every chart
widget in the draft and drops record filters (and now-orphaned groups)
whose `fieldMetadataId` is not in the widget object's set of active
fields. It leaves non-chart widgets untouched and leaves filters intact
when the object metadata can't be resolved (avoids wiping valid filters
during metadata loading).
- `useSavePageLayout` — builds a `Map<objectMetadataId,
Set<activeFieldId>>` from `useObjectMetadataItems()` and sanitizes the
draft before converting it to the update input.

This layer only ever removes filters whose field is genuinely
deactivated/deleted — the exact set the backend rejects — and never
removes filters pointing at valid fields.

## Tests

- `dropChartRecordFiltersWithDeletedFields.test.ts` — extended coverage
for orphaned filter-group cleanup.
- `sanitizeChartFiltersInPageLayoutDraft.test.ts` — new: drops
deactivated/deleted-field filters on save, keeps valid filters, cleans
up orphaned groups, leaves non-chart widgets alone, and leaves filters
untouched when object metadata is unresolved.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21958?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. -->

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-22 18:15:33 +02:00
Thomas des Francs 6ce7d04f9d Fix field widget textarea focus reset (#21959)
Short description: Keeps the field widget textarea on a local draft
value while focused so record-store rewrites do not reset the active
caret.

# Before

Typing in an editor-mode text field can lose caret position when the
global record store is rewritten by an external record update/refetch.


https://github.com/user-attachments/assets/1ee7a819-2c27-4d09-aae5-814c2cf27181


# After

The focused textarea should preserve the in-progress draft and caret
while still updating sibling previews optimistically and flushing the
final value on blur.


https://github.com/user-attachments/assets/41832493-021f-46e8-bc75-722bbb1cd7b7



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21959?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. -->
2026-06-22 17:45:49 +02:00
Charles Bochet b1ada79d72 fix(front): scope relation table widget via currentRecordId (#21965)
## Context

Follow-up to #21293 (merged). That PR added a bespoke
`relationTableFilter` to keep a relation field rendered as a
record-table widget scoped to the host record. It turns out to be
redundant.

## Why it's redundant

The relation table widget's view already carries an
`isCurrentRecordSelected` relation filter on the inverse field — it's
baked in by `useAddDraftViewForFieldRelationTableWidget` when the widget
is configured. That filter is resolved through the `currentRecordId`
that `FieldWidgetRelationTable` provides via
`RecordFilterValueDependenciesContext`, and
`turnRecordFilterIntoGqlOperationFilter` turns it into exactly `{
`${inverseField}Id`: { in: [recordId] } }`.

So the hand-built `relationTableFilter` duplicated a filter the existing
mechanism already produces from `currentRecordId`.

## Changes

- Remove `relationTableFilter` from
`RecordFilterValueDependenciesContext`
- Stop reading/applying it in `useFindManyRecordIndexTableParams` and
`useAggregateRecordsForRecordTableColumnFooter`
- Delete the `getRelationTableFilter` util and its test
- `FieldWidgetRelationTable` provides `currentRecordId` only

Net −263 lines; relies on the existing `isCurrentRecordSelected` +
`currentRecordId` scoping path.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21965?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. -->
2026-06-22 17:09:28 +02:00
neo773 c608792aea feat: real-time email & calendar tabs on record pages (#21953)
emails and calendar tabs only refreshed on reload, unlike timeline. this
subscribes to the participant object (messageParticipant /
calendarEventParticipant) for the record's related people over the
existing sse stream and refetches on change.

relatedPersonIds is resolved server-side so any object with the tab
inherits it, no per-object code. resolver stays the source of truth so
visibility masking is untouched.
2026-06-22 14:27:09 +00:00
rcshetty3 068a8d4efe fix(front): keep relation field record tables scoped to the host record (#21293)
## Problem

When a relation field is added to a record page as a record **table**
widget
(Page Layouts → a `FIELD` widget with `fieldDisplayMode: TABLE` and a
`viewId`),
the table renders the **global** list of the related object instead of
only the
records related to the current record.

Steps to reproduce:
1. On a Company record page layout, add a to-many relation field (e.g.
`Opportunities`) as a widget and set its display mode to **Table** with
a view
   (so it shows columns).
2. Open a Company record.
3. The Opportunities table lists *all* opportunities in the workspace,
not just
   the ones linked to that company.

Note: when the same relation widget has **no** `viewId`, it is correctly
scoped
to the record — but then it can't render custom columns. So custom
columns and
relation-scoping were effectively mutually exclusive.

## Root cause

`FieldWidgetRelationTable` renders the related records through
`RecordTableWidgetRendererContent` using the widget's `viewId`. That
path loads
the view's filters and fetches the related object's records, but **never
applies
the relation filter** that constrains the table to the host record. With
a
`viewId` present, the table therefore shows the whole object.

The relation filter itself already exists elsewhere —
`RecordDetailRelationSection` builds
``{ `${inverseRelationFieldName}Id`: { in: [recordId] } }`` for its
aggregate.
It just isn't applied on the table path.

## Fix

- Add a pure helper `getRelationTableFilter()` that builds the
host-relation
  filter for a to-many relation field (morph-aware, mirroring
  `RecordDetailRelationSection`).
- `FieldWidgetRelationTable` computes this filter and passes it down via
the
  existing `RecordFilterValueDependenciesContext` (new optional
  `relationTableFilter`).
- `useFindManyRecordIndexTableParams` (rows) and
`useAggregateRecordsForRecordTableColumnFooter` (footer aggregates) AND
this
  filter into their queries.

The filter is scoped to the relation-table instance through the context
and
defaults to `undefined`, so **every other table (record index, kanban,
dashboards, …) is unaffected** — `combineFilters` / object spread treat
the
absent filter as a no-op. No backend changes.

## Tests

- New unit tests for `getRelationTableFilter` (to-many → foreign-key
filter;
to-one → none; unresolved relation type / field → none; morph relation;
  missing morph target names → none).
- `nx typecheck twenty-front`, `nx lint twenty-front`, and the new
  `nx test twenty-front` suite pass locally.

## Screenshots

Same record (a "Centre" with 0 related theory allocations and 34 related
orders), same page-layout (relation fields shown as Table widgets with a
view).

**Before** — with a `viewId`, the relation tables show the *global*
lists: the
Theory Allocations table is full of allocations belonging to *other*
records,
and Collateral Orders shows 60 (the whole object's first page) instead
of 34.

<!-- drag the BEFORE screenshot here -->

**After** — the same tables are scoped to the record: Theory Allocations
is
empty (this record has none) and Collateral Orders shows exactly its 34
orders,
with the view's columns (Status / Total Value / Date).

<!-- drag the AFTER screenshot here -->

## Verification

Verified on a self-hosted instance running the equivalent change (the
four
touched files are byte-identical on `main` and the latest release tag):
a
relation table widget with a `viewId` now shows only the host record's
related
rows **with** the view's columns, the footer aggregates match the
visible rows,
and the global record index is unchanged. Confirmed across records with
different related-record counts (e.g. a record with 34 related orders
shows 34;
a record with 1 shows 1; records with 0 show an empty table).

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-22 16:26:33 +02:00
nitin eeca9cd42e fix(front): isolate record table dashboard widget filters on duplicate (#21936)
closes
https://discord.com/channels/1130383047699738754/1518291134382608394



https://github.com/user-attachments/assets/931e4e88-44e8-4634-a7f9-e0564bd80fff



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21936?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. -->
2026-06-22 16:27:01 +05:30
nitin 6237598a30 Fix meeting bot CalendarEvent field visibility and editability (#21883)
- Add the meeting bot preference field to the CalendarEvent record page
fields view.
- Use a Standard-app ownership gate for record field read-only logic.
- Allow app-owned and workspace-custom fields on system objects to
follow isUIEditable and permissions.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21883?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. -->
2026-06-22 11:24:32 +02:00
Priyanshu Bartwal 4c966bfc32 [Twenty-front]: Bunch of View Picker Fixes and improvements. (#21290)
While working on #21208, I found a few related improvements and fixes
that were worth including in this PR.

1. Improved View Picker UX:
- Added optimistic updates when selecting a view from both the
drag-and-drop view picker
- Added optimistic updates when editing view. Before it used to close
the whole dropdown.
- Added highlighting for the currently selected view.
- Before:



https://github.com/user-attachments/assets/469fc60c-e65f-4452-a5a4-7df6188ab19d


- After:


https://github.com/user-attachments/assets/d3b151c1-0c10-45e7-a796-b5e6061c898d



2. Remove Favorites from the View Picker
- Added support for removing a favorite directly from the view picker
without needing to open additional menus.
- Before:


https://github.com/user-attachments/assets/70437fb9-d4c1-488b-aab9-0ea92d1bad99



- After:


https://github.com/user-attachments/assets/442546bd-24ae-43d5-abe1-268ef3ff6475

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-22 10:56:34 +02:00
Félix Malfait 2abf9c2930 feat(workflow): Pick Record load balanced strategy (3/3) (#21902)
## Overview

Final PR in the Pick Record stack. Adds the **Load Balanced** strategy:
pick the candidate that currently has the *fewest related records*. This
is the "fair assignment" mode — e.g. assign a new company to the account
owner who currently owns the fewest companies, or route a lead to the
rep with the fewest open opportunities.

**Stacked on #21900** (which is stacked on #21899) — merge in order.
This PR's diff against `main` includes PRs 1 & 2 until they merge.

## What changed

- Widened the `strategy` enum to add `LOAD_BALANCED`, and added an
optional `loadBalance: { objectNameSingular, fieldName }` to the action
input.
- Editor: selecting **Load balanced** reveals a **Balance by** object
picker and a **Count by** field picker (the related object's many-to-one
relation fields).
- Executor: for each candidate, counts records of the chosen related
object whose chosen relation points at that candidate, then selects the
least-loaded one.

## How it works

Given pool = workspace members and config `{ objectNameSingular:
"opportunity", fieldName: "pointOfContact" }`, the executor counts, per
member, the opportunities whose `pointOfContact` is that member, and
picks the member with the lowest count.

## Design decisions & tradeoffs

1. **No persistent state — computed live each run.** Unlike round robin,
load balancing reads current data, so there's no cursor to store.
Correct by construction even under concurrency (each run recomputes
counts); the only caveat is two simultaneous runs can both see the same
"least loaded" candidate before either assignment lands (a small,
self-correcting skew), which is inherent to load-balancing and
acceptable.

2. **Count via per-candidate queries.** One filtered count per candidate
(`{ [relationField]: { id: { eq: candidateId } } }`), run in parallel.
For the realistic pool sizes this targets (a team), this is simple and
clear. A single `group_by` aggregate would scale better for very large
pools — noted as a future optimization, deliberately not done to keep
the logic obvious.

3. **Deterministic tie-break.** Candidates are pre-sorted by id (shared
with round robin), and the first minimum wins — so equal-load ties
resolve deterministically rather than arbitrarily.

4. **`Count by` lists all many-to-one relations of the chosen object**
(not filtered to those targeting the pool object). Keeps the editor
simple; picking an unrelated field just yields zero counts, which is
visibly wrong. Filtering options to relations that target the pool
object is a nice follow-up.

5. **Filter on the counted set** (e.g. only *open* opportunities) is
intentionally out of scope for this first cut — documented as a
follow-up.

## Testing

Added `pick-record-load-balanced-workflow.integration-spec.ts`: creates
two fresh companies (0 related opportunities each), attaches one
opportunity to the second, configures `LOAD_BALANCED` counting
opportunities by `company`, and asserts the step picks the **first**
company (0 < 1). Passes locally alongside the random and round-robin
tests (3 suites / 4 tests). `typecheck` + `lint:diff-with-main` green
for shared/server/front.

## The full stack

1. #21899 — Random (the action + the whole scaffold)
2. #21900 — Round robin (atomic Redis cursor)
3. this — Load balanced

Together these enable round-robin / load-balanced / random **assignment
workflows** in Twenty, composed via the standard variable picker (assign
the chosen record downstream with `{{step.<id>.id}}`).

https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8

---
_Generated by [Claude
Code](https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21902?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. -->
2026-06-22 07:09:15 +02:00
Félix Malfait fa6d1394af feat(workflow): Pick Record round robin strategy (2/3) (#21900)
## Overview

Second PR in the Pick Record stack. Adds a **Round Robin** selection
strategy alongside Random, so an assignment workflow can distribute
records *evenly* across a candidate pool (e.g. rotate company ownership
across a set of workspace members) rather than just randomly.

**Stacked on #21899** — review/merge that one first. This PR's diff
against `main` includes PR 1's commits until #21899 merges.

## What changed

- Widened the `strategy` enum (`RANDOM` → `RANDOM | ROUND_ROBIN`) in the
shared schema and the server input type.
- Editor now shows a **Strategy** selector (Random / Round robin). The
candidate-pool label changed from "Pick at random from" to the neutral
"Pick from" since random is no longer the only mode.
- Executor implements round robin.

## Design decisions & tradeoffs

1. **State store: Redis `incrBy` (atomic), keyed
`pick-record:round-robin:{workspaceId}:{stepId}`.** Round robin needs a
persistent cursor, and workflow runs are **not** serialized — two runs
can execute the same step concurrently — so the increment must be
atomic. `CacheStorageService.incrBy` (workflow cache namespace) is a
single atomic Redis op, needs no schema change, and is already
injectable. Index = `(cursor - 1) % poolSize`.

**Tradeoff — durability:** a Redis flush/eviction resets the cursor,
which restarts the cycle from an offset. That causes a one-time
*fairness drift*, never a *correctness* bug (no double-assignment, since
each increment is atomic). If strict durability is ever required, the
cursor can move to a Postgres counter table with `INSERT … ON CONFLICT …
DO UPDATE SET cursor = cursor + 1 RETURNING cursor` (atomic + durable) —
deliberately **not** done here to avoid a migration for what is, in
practice, an acceptable reset.

2. **Deterministic pool ordering.** The resolved pool is sorted by `id`
before the cursor is applied, so position→record mapping is stable
run-to-run regardless of fetch order. Without this, round robin wouldn't
reliably cycle.

3. **Cursor key uses `stepId`.** Stable across runs of a published
version. Republishing a version may mint new step ids, which resets the
cursor — acceptable and documented here.

4. **Slot-on-increment.** The cursor increments when the step runs
(reserving a position); if a later step in the run fails, that position
is effectively skipped. Minor, acceptable unfairness — flagged rather
than adding cross-step compensation.

## Testing

Added `pick-record-round-robin-workflow.integration-spec.ts`: builds a
workflow with a 3-record pool and `ROUND_ROBIN`, runs it 4 times
sequentially, and asserts the picks are exactly `[p0, p1, p2, p0]` (full
cycle + wraparound) against the deterministically-ordered pool. Passes
locally alongside PR 1's random test (2 suites / 3 tests). `typecheck` +
`lint:diff-with-main` green for shared/server/front.

## Follow-up

- PR 3: `LOAD_BALANCED` (fewest related records wins).

https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8

---
_Generated by [Claude
Code](https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21900?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. -->
2026-06-21 22:16:26 +02:00
Félix Malfait 573fd00ea7 feat(workflow): add Pick Record action (1/3 — random selection) (#21899)
## Overview

Adds a new workflow action, **Pick Record**, that selects **one** record
from a configured candidate pool and exposes the chosen record as the
step's output. Downstream steps can then reference it through the normal
variable picker — e.g. assign an owner in an _Update Record_ step by
setting **Account Owner = `{{step.<pickRecordId>.id}}`**.

This is the foundation for building **assignment workflows**
(round-robin / load-balanced owner assignment, reviewer rotation, etc.)
in Twenty.

## This is PR 1 of a 3-PR stack

| PR | Strategy | Adds |
|----|----------|------|
| **1 (this one)** | `RANDOM` | The whole `PICK_RECORD` action,
end-to-end, stateless |
| 2 | `ROUND_ROBIN` | A persistent, atomically-incremented per-step
cursor + the strategy selector UI |
| 3 | `LOAD_BALANCED` | "fewest related records wins" via an aggregate
count |

Each PR widens the `strategy` enum (a backward-compatible change), so no
data migration is needed between them.

## How it works

- **Editor**: pick an Object, then pick the candidate records (a
multi-record selector). A random record is selected from that pool at
run time.
- **Output**: a single record of the chosen object — the same output
shape as `CREATE_RECORD`/`UPDATE_RECORD` — so it drills into
`{{step.x.id}}`, `{{step.x.name}}`, … in the variable picker.
- **Execution**: reuses `FindRecordsService` to fetch the pool (`id IN
(recordIds)`, which also transparently drops any deleted candidates),
then returns one at random.

## Design decisions & tradeoffs

1. **Standalone step that outputs a variable, not an inline "random"
mode on the relation field.** This mirrors Attio's round-robin block.
The decisive reason is composition: the chosen record is almost always
reused (assign owner **and** create a follow-up task for them **and**
email them). A variable is chosen once and reused everywhere; an inline
per-field value would re-roll independently in each place. It also keeps
the (stateful) round-robin/load-balanced logic out of the field inputs.
Tradeoff: one extra step to wire up vs. an inline control — accepted for
the composability win. An inline "Assign automatically" entry point can
still be layered on later as sugar that inserts this step.

2. **Co-located in the `record-crud` action module and reuses
`FindRecordsService`.** Avoids duplicating module wiring (auth context,
permissions, object-metadata resolution) and the data-access path.
Tradeoff: "Pick" is a selection rather than a CRUD op, so the folder
name is slightly broad; chose reuse + low risk over a separate module.
Can be extracted if the family grows.

3. **`strategy` exists in the schema (defaulted `RANDOM`) but the
selector is hidden in this PR.** A dropdown with a single option would
be UX slop, and adding the field only in PR 2 would force a data
backfill for any `PICK_RECORD` steps created in between. Keeping the
field now (hidden) avoids both. PR 2 introduces the selector once
there's a real choice.

4. **Pool is an explicit static list (`recordIds`) for v1.** Matches the
most common assignment case ("rotate among these N people") and reuses
the existing `FormMultiRecordPicker`. A filter-based pool (reusing the
Find Records filter UI) and a list-from-a-previous-step pool are natural
follow-ups, intentionally out of scope here to keep the stack focused on
the three strategies.

5. **Output schema is computed on the frontend** (like `CREATE_RECORD`),
derived from `input.objectName` — so it is **not** added to
`PERSISTED_OUTPUT_SCHEMA_TYPES` and needs no server-side schema
computation.

6. **Validation**: `PICK_RECORD` is added to object-name metadata
validation (so a deleted/invalid target object is flagged) via a
dedicated `OBJECT_TARGETING_ACTION_TYPES` set — deliberately **not** to
`VARIABLE_CONSUMING_ACTION_TYPES`, because a static pool legitimately
references no upstream variable and would otherwise raise a spurious "no
variable reference" warning.

7. **Empty pool → step error** at run time (respecting the step's
error-handling options) rather than a silent no-op, since an empty pool
is a misconfiguration or fully-deleted set.

8. **`Math.random`** is used for selection — no cryptographic guarantee
is needed for assignment fairness.

## Testing

Per our testing convention (integration test over service/`.spec`
tests): added `pick-record-workflow.integration-spec.ts`, which builds a
workflow with a manual trigger + a `PICK_RECORD` step, configures a
known two-record pool, runs it, and asserts the run completes and the
picked record is **always** within the configured pool (verifying the
pool filter) across repeated runs.

Local verification (typecheck + lint for shared/server/front) is green;
running the integration suite and attaching editor screenshots in a
follow-up comment.

## Follow-ups

- PR 2: `ROUND_ROBIN` + persistent atomic cursor (Redis `incrBy` vs. a
Postgres counter table — tradeoff to be documented on that PR) +
strategy selector.
- PR 3: `LOAD_BALANCED`.
- Later (not in this stack): filter-based / variable-list pools, an
inline "Assign automatically" entry point on relation fields, OOO-skip /
weighting.

https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8

---
_Generated by [Claude
Code](https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21899?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. -->
2026-06-21 21:51:16 +02:00
Charles Bochet f4219449db fix(front): prevent AI agent output field error message from overlapping the Type field (#21921)
## Problem

Follow-up to #21834, found during QA.

That PR added an inline validation error on the AI Agent **Output →
Variable Name** field. The error is rendered with `InputErrorHelper`,
which is `position: absolute`. When the message wraps to two lines
(which it does at the side-panel width), it is taken out of the layout
flow and **overlaps the "Type" selector** directly below it:

```
Variable Name
[ sdlfkj sdlkj          ]
Use only letters, numbers, underscores, dots or hyphens (max 64
Type   <-- overlapped by the error message
[ Text                ▾ ]
```

## Fix

Render the error with `InputHint danger` instead of `InputErrorHelper`,
matching how the sibling `FormNumberFieldInput` already shows its
errors. `InputHint` flows in the column (`margin-top`, not absolute), so
the error reserves its own space and pushes the following fields down
instead of overlapping them.

This is a one-line behaviour change in `FormTextFieldInput`; no new
component or styling is introduced.

## After

The `Type` field is pushed below the wrapped error message with correct
spacing:


![after](https://raw.githubusercontent.com/twentyhq/twenty/pr-21921-assets/repro-after.png)

## Tests

- Added a `WithError` story to `FormTextFieldInput` (mirrors the
existing `FormNumberFieldInput` `WithError` story) asserting the error
message is visible.

## QA

Reproduced and verified in Storybook against the real
`WorkflowOutputSchemaBuilder` (throwaway story, not committed): before
the fix the error overlapped `Type`; after the fix the `Type` field is
pushed below the wrapped message with correct spacing.
2026-06-21 18:13:40 +02:00
Charles Bochet 334e962ab5 fix: cannot create record from table view — empty morph to-many relation returns null (#21846)
## Problem

Creating a record from the table view (reproduced on **People**) crashes
the client even though the `createOne…` mutation succeeds server-side,
so the record never appears:

```
Cannot read properties of null (reading 'map')
  getRecordConnectionFromRecords → getRecordNodeFromRecord → optimistic cache effect → createOneRecord
```

## Root cause

An empty **morph** to-many relation comes back as `null`, while every
other to-many relation comes back as `{ edges: [] }`. The frontend then
runs `null.map` while building the optimistic cache node; the error
escapes the mutation `update`, the rollback evicts the record, and it
never lands in the table.

## Fix

**Server** — plain to-many relations are hydrated to `[]` and formatted
to `{ edges: [] }` by `ObjectRecordsToGraphqlConnectionHelper`; an empty
morph to-many was left undefined and the field was skipped (→ `null`).
Default an unset to-many value to `[]` so it goes through the **same
connection path as plain to-many relations**.

**Frontend** — defensive guard in `getRecordNodeFromRecord`: a to-many
relation whose value isn't an array is skipped instead of crashing,
mirroring the existing guard in `extractTargetRecordsFromRelation`.
Needed regardless, since cached data / SSE / older servers still send
`null`.

## Tests

- Unit: `getRecordNodeFromRecord` skips a null to-many (reproduces the
exact crash without the guard).
- Integration: an empty morph `ONE_TO_MANY` read returns `{ edges: []
}`, not null.
2026-06-21 18:05:04 +02:00
Félix Malfait a0689d1577 feat(workflow): condition filter on database-event triggers (#21868)
## Problem

Connecting a mailbox bulk-creates contacts via the email/calendar sync,
and each `person.upserted` fires the seeded **"Create company when
adding a new person"** workflow. The trigger enqueues one run per record
(no batching) and each run bills several `WORKFLOW_NODE_RUN` events — so
a single mailbox connect can rack up tens of thousands of runs and
exhaust credits on a brand-new workspace. The workflow is also redundant
on that path: the sync already creates the company from the email domain
and links the person to it.

## What this does

Adds an optional, user-defined **filter** to database-event (listener)
triggers, evaluated in the listener **before a run is enqueued**.
Non-matching events never create a run, so they consume zero execution
credits. This is the Filter node's capability, lifted to the trigger
level, and available for all event types (created / updated / upserted /
deleted).

The seeded "Create company when adding a new person" workflow now
carries a visible trigger filter — `Created by → Source is not Email`
**and** `is not Calendar` — so it no longer runs for sync-created
contacts, while still running for manually / API / CSV-added people.

## How (reuse)

- **Backend:** extracted `evaluateStepFilters()`, shared by the Filter
action and the trigger listener's new `eventMatchesRecordFilter` gate.
The record is exposed under the `trigger` key so filters reference it
exactly like steps do (`{{trigger.properties.after.…}}`).
- **Shared:** one optional `filter` added to the database-event trigger
zod schema; the front-end type derives from it (settings stay JSON — no
codegen).
- **Frontend:** extracted `WorkflowStepFilterBuilder` from the Filter
action's body; both the Filter action and the trigger editor render it.
The field picker needed no changes — at the trigger it already resolves
to the record's own fields via `TRIGGER_STEP_ID`.

## Scope / decisions

- **No migration for existing workspaces** (by request) — only newly
created workspaces get the filtered default; already-created workspaces
keep the always-on workflow.
- Deliberately did **not** add relation-enrichment to the upsert path
(it would add a DB lookup to the very bulk-sync path we're relieving).
Trigger filters work on the record's own scalar/composite fields (e.g.
`createdBy.source`); relation-based filters work on created/updated
where enrichment already runs.

## Verification

- Typecheck: `twenty-shared`, `twenty-server`, `twenty-front` all green.
- Lint (diff, autofix): 0 warnings / 0 errors across all three.
- Unit tests: a new `evaluate-step-filters` spec exercising the exact
`createdBy.source IS_NOT` seed mechanism, plus new listener specs
proving non-matching events are not enqueued. All backend
filter/listener suites pass.
- Not run here: integration tests (need a DB) and Storybook.

https://claude.ai/code/session_013k36vfekDppwRCgM6Ha7De

---
_Generated by [Claude
Code](https://claude.ai/code/session_013k36vfekDppwRCgM6Ha7De)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21868?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. -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-21 15:47:06 +00:00
Weiko f8db73598c Fix dangling relation fields crashing records after deleting a custom object (#21874)
Fixes https://github.com/twentyhq/twenty/issues/21706

## Context
Deleting a custom object that has relation/junction fields pointing to
it (e.g. a junction object linked from Person and Company) crashes
record pages with `Target object metadata item not found for <field>`.
The backend cascade correctly deletes the related relation fields, view
fields and page-layout widgets, but the frontend metadata store only
removed the deleted object itself, leaving dangling relation fields (and
stale UI-layer references) behind.

## Fix
After a successful deletion, `useDeleteOneObjectMetadataItem` now calls
`invalidateMetadataStore()`, triggering the existing reconcile path that
refetches objects, fields, indexes, views, view fields and page-layout
widgets. This removes
the dangling relations and cleans up the UI layers in one consistent
pass (also replacing the previous manual command-menu refetch).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21874?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. -->
2026-06-21 17:26:41 +02:00
Abdul Rahman 544c89119c fix: hide restricted objects and views nested in navigation folders (#21914)
Closes #20141 

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21914?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. -->
2026-06-21 17:24:55 +02:00
Félix Malfait b8ea742a88 fix(front): respect user number format for counts and aggregates (#21894)
## Problem

Several user-facing numbers were rendered raw (e.g. `153909`) instead of
honoring the workspace member's **Number format** preference (e.g. `153
909` with `Spaces and comma`). The formatting utilities already existed
(`formatNumber` / `useNumberFormat`) but were not applied on these
surfaces.

## Root cause

`transformAggregateRawValueIntoAggregateDisplayValue` — the shared
helper behind every table/board/chart aggregate — returned the `COUNT`
branch as a raw string and never threaded the user's locale format into
`formatNumber` for the other branches (so they silently fell back to
`COMMAS_AND_DOT`).

Its existing `numberFormat` param actually held the chart `SHORT`/`FULL`
abbreviation setting, so it is renamed to `chartNumberFormat`, and a new
`numberFormat: NumberFormat` now carries the locale separators.

## Surfaces fixed

- Record table footer aggregates, including the raw **"Count all"**
total
- Record board column / group-section aggregates
- Aggregate chart and pie-chart center metric (including their raw
`COUNT` early-returns)
- View picker `<view> · <count>` total
- Record show breadcrumb pagination `(x/y)`
- Record index header and side panel `N selected` counts

The board-column header needs no change — it now receives an
already-formatted string from the transform.

## Out of scope (intentionally left raw)

The editable `SettingsCounter` input (formatting would break parsing),
the advanced-filter pill, the `+N` overflow badge, and the AI routing
debug display.

## Testing

- New + existing unit tests pass
(`transformAggregateRawValueIntoAggregateDisplayValue`, `formatNumber`,
`useNumberFormat`), with added locale-aware coverage (`SPACES_AND_COMMA`
→ `153 909`, `DOTS_AND_COMMA` → `153.909`).
- `nx typecheck twenty-front`, oxlint and oxfmt on the diff all pass.

> Note: two i18n strings change placeholder shape (`{count} selected` →
`{0} selected`); a `lingui:extract` will refresh the catalogs (runtime
falls back to source text meanwhile).

https://claude.ai/code/session_013XNL2Xa11Bw7fsnPFQgsGX

---
_Generated by [Claude
Code](https://claude.ai/code/session_013XNL2Xa11Bw7fsnPFQgsGX)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21894?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. -->
2026-06-20 14:28:12 +02:00
Raphaël Bosi f9084fd208 Clear stale parent-view filters on front-component cross-object navigation (#21869) 2026-06-19 19:21:54 +02:00
Raphaël Bosi fecf699bc5 Fix broken CSV import grid layout (#21867)
## What

Import `react-data-grid/lib/styles.css` in `SpreadsheetImportTable`, the
single component that renders the import grid (used by the Validate Data
and Select Header steps).

## Why

The React 19 migration (#21531) bumped `react-data-grid` from
`7.0.0-beta.13` to `7.0.0-beta.59`. The old beta auto-injected its
layout CSS; beta.59 ships it as a separate
`react-data-grid/lib/styles.css` export that must be imported manually.
It was never imported, so the grid lost its base layout (grid template,
row heights, cell positioning): rows stacked at full height and columns
no longer aligned. The library scopes its styles under `@layer rdg`, so
the existing Linaria theme overrides still take precedence.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21867?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. -->


## Before
<img width="2540" height="1448" alt="CleanShot 2026-06-19 at 17 43
58@2x"
src="https://github.com/user-attachments/assets/a208b518-9088-4988-8245-4fdc4f8bc8de"
/>


## After
<img width="2454" height="1392" alt="CleanShot 2026-06-19 at 18 02
36@2x"
src="https://github.com/user-attachments/assets/e0b30d71-8244-4363-86aa-60b12a2dfdd9"
/>
2026-06-19 18:10:12 +02:00
Thomas Trompette 15fd236ad0 fix(workflow): add tooltip explaining why the variable picker is disabled (#21862)
## Context

Closes #21773
<img width="448" height="301" alt="Capture d’écran 2026-06-19 à 16 33
56"
src="https://github.com/user-attachments/assets/4efc637e-3361-4108-86b6-92ffc2e84252"
/>


When a workflow's variable picker (the `+` button next to a field) is
disabled — e.g. on a step whose only trigger is a global manual trigger
that produces no record variables — the button just shows a
`not-allowed` cursor with no explanation of *why*.

## Change

Add an `AppTooltip` to the disabled state of `WorkflowVariablesDropdown`
explaining the reason:

> No variables are available yet. Variables come from the workflow
trigger and previous steps.

The disabled state is reached via `disabled === true ||
noAvailableVariables`. In practice the callers hide the picker entirely
in read-only mode (it's rendered only when `!disabled`/`!readonly`), so
the meaningful trigger is **no available variables** — hence a single
message rather than separate copy per reason.

The tooltip is anchored with a `data-*` attribute selector instead of an
`#id`, because the picker's `instanceId` comes from React's `useId()`
(values like `:r1:`) which are invalid in a CSS `#id` selector that
`AppTooltip` runs through `querySelectorAll`.

## Testing

- `nx lint:diff-with-main twenty-front` — passes (lint + format).
- Verified the component resolves/renders on a local instance running
this branch.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21862?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. -->
2026-06-19 16:37:07 +02:00
nitin 973b35989e Add standard record page layout for calendar events (#21857)
Moves calendar event details from the bespoke side-panel page to the
standard record page layout system.

- Adds standard calendar event record page metadata, fields view,
widgets, tests, snapshots, and upgrade command for existing workspaces.
- Opens calendar events through the generic ViewRecord side-panel path.
- Adds participants and call recordings as standard field widgets.
- Removes the old custom calendar event side-panel page and related
side-panel enum/config entry.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21857?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. -->




https://github.com/user-attachments/assets/c1f88cac-1615-478c-a3dd-87d0c61ab9a8

<img width="3024" height="1658" alt="CleanShot 2026-06-19 at 19 01
27@2x"
src="https://github.com/user-attachments/assets/c3df5705-ff08-446e-ac3c-6ccb11cf21ec"
/>

<img width="3024" height="1658" alt="CleanShot 2026-06-19 at 19 01
19@2x"
src="https://github.com/user-attachments/assets/633525db-310c-4462-8458-a72068cc1432"
/>
2026-06-19 20:06:33 +05:30
Félix Malfait 23f5ba9ebf feat: add resizable kanban column width (#21828)
## What & why

Lets users resize the columns of a Kanban (record board) view. Requested
by a user; the design avoids the "ragged board" problem by making the
width a **single shared value**.

## Behaviour

- A drag handle appears on the right edge of every column header.
- Because all columns read **one** width value, dragging any handle
resizes **every** column together — they can never end up mismatched.
- Width is clamped between **150px** and **400px** (default **200px**).
- The width is **persisted per view** and restored on reload.

## Approach

**Backend** — a new nullable `View.kanbanColumnWidth` field, threaded
through the existing view-level setting pattern (the same one
`kanbanAggregateOperation` / `shouldHideEmptyGroups` use), so it gets
create/update/manifest/override support for free:
- entity column + `ViewOverrides` + `@WasIntroducedInUpgrade`
- `CreateViewInput` / `UpdateViewInput` (`Int`, `@Min(150)`/`@Max(400)`)
+ `ViewDTO`
- flat-view editable properties, entity-properties config, compare-type,
standard-view + manifest converters
- a fast instance command adding the `core.view` column

**Frontend** — the value hydrates into a view-scoped atom and drives a
single CSS variable set on the board container, which both column
headers and bodies read. Live dragging only writes that CSS variable (no
per-move React re-render); the final width is committed to the atom and
persisted via `updateView` on pointer-up.

## Nullability / defaults

`kanbanColumnWidth` is nullable — `null` means "never resized" and the
UI falls back to the 200px default, so existing rows need no backfill.

## Validation

- `nx typecheck twenty-server`  and `nx typecheck twenty-front` 
- `nx lint:diff-with-main twenty-server` ; frontend lint fixes applied
(split constants to one-per-file, removed `useRef`-for-state in favour
of `useState`).
- Draft pending a final green CI run (the dev container reclaimed
`node_modules` mid-session; re-running locally).

## Test plan

- [ ] Drag a kanban column edge → all columns resize together, clamped
150–400px
- [ ] Reload → width persists for that view; other views unaffected
- [ ] A view that was never resized still renders at 200px

https://claude.ai/code/session_016Qe6oDBkhVbrq2QkXBJ5nE

---
_Generated by [Claude
Code](https://claude.ai/code/session_016Qe6oDBkhVbrq2QkXBJ5nE)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21828?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. -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-19 15:58:13 +02:00
Raphaël Bosi 1f6c2b89fd Accessibility guardrails and component hardening for twenty-ui (#21848)
Builds on twenty-ui's existing runtime axe gate by adding a static
enforcement layer and fixing accessibility gaps in shared components.
Color contrast is intentionally out of scope (still deferred via
`A11Y_DEFER_COLOR_CONTRAST`).

## What changed
- **Static guardrails:** enabled oxlint's `jsx-a11y` plugin
(keyboard-operability rules at `error`), and added a custom
`twenty/no-storybook-a11y-disable` rule that blocks `a11y: { test: 'off'
| 'todo' }` so the axe gate can't be silently disabled again.
- **Focus visibility:** wired the existing `focus-ring` mixin into all
buttons for real `:focus-visible` rings (was `outline: none`).
- **Decorative icons:** `aria-hidden` on icons inside labeled buttons
(added to `IconComponentProps` + render sites).
- **Inputs:** accessible-name support on `SearchInput` and `Checkbox`.
- **Interactive components:** `Tag` renders a real `<button>` when
clickable; the non-semantic clickable `div`s (`Avatar`, `Status`,
`ColorSchemeCard`, `NavigationBarItem`, etc.) are now keyboard-operable
via a shared `handleClickableElementKeyDown` helper, role and accessible
name.

## Notes for reviewers
- Two `oxlint-disable` lines remain on genuine non-interactive capture
wrappers (`CodeEditor`, `OverflowingTextWithTooltip`).
- 8 lint warnings remain by design: conditional-interactivity
`no-static-element-interactions` and legitimate `autoFocus` on
`SearchInput`.
- `NavigationBarItem` gained a required `ariaLabel`; its only consumer
(`MobileNavigationBar`) is updated with translated labels.

## Follow-ups (separate PRs)
- Enforced accessible names on icon-only buttons
(`IconButton`/`LightIconButton`) — breaking, ~128 call sites.
- `aria-activedescendant` wiring for the dropdown/listbox keyboard
layer.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21848?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. -->
2026-06-19 15:57:23 +02:00
Thomas Trompette 34a2037b65 fix(front): keep record table footer visible below information banner (#21852)
## Context

Fixes #21765.

<img width="1439" height="961" alt="Capture d’écran 2026-06-19 à 15 03
46"
src="https://github.com/user-attachments/assets/07104099-19de-45f9-9cca-eaaacbc326af"
/>

When a page-level information banner is visible on a record table (e.g.
the mailbox **"Sync lost with mailbox … Please reconnect"** banner), the
table footer / bottom edge was hidden behind the card boundary. As a
side effect, drag-select **auto-scroll never triggered** near the
bottom, because the cursor could not reach the scroll wrapper's real
bottom edge.

## Root cause

In `PageCardLayout`, the `InformationBannerWrapper` and the page
children are siblings in a flex column. The record index child
(`StyledIndexContainer`) used `height: 100%`, so it demanded the
**full** body height regardless of the banner. With a banner present,
banner height + 100% exceeded the card, and since the container's
content (the table) has a large min-content height it would not shrink —
so the bottom (the footer) was pushed past `StyledCard`'s `overflow:
hidden` and clipped.

`useDragSelectWithAutoScroll` only scrolls when the cursor is within
`AUTO_SCROLL_EDGE_THRESHOLD_PX` (20px) of `containerRect.bottom`. With
the bottom edge clipped off-screen, that zone was unreachable, so
auto-scroll appeared broken.

## Fix

Replace `height: 100%` with `flex: 1; min-height: 0;` so the container
takes the space **remaining** after the banner — the same flex idiom its
parent `StyledBodyContent` already uses. When no banner is shown, the
banner wrapper collapses to `height: 0`, so the table fills the full
height exactly as before (no behaviour change in the common case).

## Testing

- Verified locally: with the mailbox reconnect banner forced visible on
the Companies table, the footer (aggregate row) stays visible and
drag-select auto-scroll reaches the bottom.
- No change when no banner is present.

This is a layout fix, not a drag-select threshold change — as suggested
in the issue, raising the threshold would only mask the layout problem.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21852?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. -->
2026-06-19 15:39:22 +02:00
Abdul Rahman 0758a4fcef reset filter search input on field select (#21850)
### Before



https://github.com/user-attachments/assets/3e5d2193-c638-4898-a11b-a9a1b9607206





### After



https://github.com/user-attachments/assets/99eab3e2-8475-46dd-915b-0324ada53e5a





<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21850?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. -->
2026-06-19 18:56:00 +05:30
Thomas Trompette a59ed80422 fix(front): allow null subfields in Phones default value so Save enables (#21847)
## Problem

Closes #21780.

When editing a **Phones** field in Settings → Data Model, changing the
**Default Country Code** does not enable the Save button — the form
becomes dirty but never valid, so the change can't be saved.

## Root cause

The settings form validates `defaultValue` with the record-value
`phonesFieldValueSchema`, which requires non-null strings:

```ts
primaryPhoneNumber: z.string(),
primaryPhoneCountryCode: z.string(),
```

But a Phones default value can legitimately have **null** subfields — a
default country code with no default number. The backend normalizes
empty subfields to `null`
(`nullify-empty-phones-default-value.util.ts`), and the shared contract
`FieldMetadataDefaultValuePhones` is `string | null`. So an existing
field whose stored default has `primaryPhoneNumber: null` makes the form
**permanently invalid**: changing the country code preserves the null
number → `isValid` stays `false` → `canSave = isDirty && isValid` keeps
Save disabled.

The sibling **address** field doesn't have this bug because
`addressFieldValueSchema` already makes every subfield `.nullable()`.
Phones was simply inconsistent.

## Fix

Add a dedicated `phonesFieldDefaultValueSchema` with nullable subfields
(mirroring the address pattern and matching
`FieldMetadataDefaultValuePhones`) and use it in the Phones settings
form. The stricter record-value `phonesFieldValueSchema` is left
untouched, so record input/persistence/empty-checks are unaffected.

## Test plan

- [x] Unit test covering the partial-null default value (and asserting
the record-value schema still rejects it)
- [x] `nx typecheck twenty-front` clean
- [x] `nx lint:diff-with-main twenty-front` clean
- Manual: open a Phones field, set a Default Country Code and save,
re-open, change the country code → Save now enables.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21847?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. -->

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 13:07:37 +00:00
Charles Bochet 0064ff6741 fix(ai): validate AI agent output field names against schema-key constraint (#21834)
## Problem

On a self-hosted instance, an AI Agent workflow action fails at run time
with an opaque model error:

```
The model returned the following errors: tools.0.custom.input_schema.properties:
Property keys should match pattern '^[a-zA-Z0-9_.-]{1,64}$'
```

This is Anthropic's validation on tool `input_schema` **property keys**.
An AI Agent's structured **Output** fields are turned into a JSON schema
and passed to the model as a tool; each output **variable name** becomes
a property key. Anthropic rejects any key that does not match
`^[a-zA-Z0-9_.-]{1,64}$` — most commonly a name containing a **space**
(e.g. `meetings brief`), but also names over 64 characters or with other
symbols.

Until now nothing validated this: `fieldsToSchema` writes
`properties[field.name]` verbatim, so a bad name only failed once the
workflow executed, with an error that gives the user no idea what to
fix. It doesn't reproduce on every instance — it depends purely on how
the workflow's output variables happen to be named.

## Fix

Introduce a single shared check,
`isValidAgentResponseSchemaPropertyKey`, and enforce it in two places:

- **Backend** — `validateAgentResponseFormat` now rejects invalid output
field names at agent **save time** with a clear `userFriendlyMessage`,
instead of letting the broken schema reach the model. This also gates
agents created via the API and re-saves of existing bad data.
- **Frontend** — the output schema builder shows an inline error on the
Variable Name field as soon as an invalid name is entered.

## Tests

- Unit test for the shared validity check (valid + invalid cases:
spaces, leading space, empty, > 64 chars, symbols, unicode).
- Unit test for `validateAgentResponseFormat` covering text/json
formats, valid names, a space in a name, an over-length name, and
reporting multiple invalid names at once.

## Notes for the reporter

The immediate unblock for an affected workflow is to rename the output
variable to remove the space (e.g. `meetings brief` → `meetings_brief`)
and retry the run. With this change the bad name is caught up front with
an explanation rather than failing mid-run.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21834?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. -->
2026-06-19 13:50:44 +02:00
Dilan Melvin T 1bd7be36e0 fix(front): recompute ExpandableList visible chips on resize (#21139)
## Summary

Relation field cells in the record table render their chips through
`ExpandableList`, which measured how many chips fit only once (during
the
children ref pass) and cached the cutoff. It recomputed on item-count
and
hover changes, but never when the cell's available width changed — so a
cell
measured while narrow stayed stuck on that count even after the column
grew
wider. This is the "only ~3 items shown even when the cell is larger"
bug.

This PR adds a `ResizeObserver` on the outer container that resets the
first
hidden child index whenever the available width changes, so the list
reveals
as many chips as fit (and re-trims when narrowed). The outer container
is
observed because its width tracks the available width independently of
how
many chips are currently rendered, which avoids a
measure → trim → shrink → re-measure feedback loop. The observer is
cleaned
up on unmount.

## Test plan

- [x] Added a Storybook interaction test
(`RecomputesVisibleChipsOnResize`)
that renders the list in a narrow container, widens it, and asserts more
      chips become visible.
- [x] Verified the test fails without the fix and passes with it.
- [x] `oxfmt` and `oxlint` pass on the changed files.
- Manual: open a record table with a to-many relation field that has
several
  linked records, widen the column, and confirm more chips appear.

Fixes #12039

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-19 13:38:39 +02:00
Parship Chowdhury 4de9f45015 fix: Edit Layout keeping the command menu open (#21161)
## Summary
- Fixes https://github.com/twentyhq/core-team-issues/issues/2460
- Engine/headless commands always skipped closing the menu
(`closeSidePanelOnCommandMenuListExecution: false`), even when the item
was not pinned. Edit Layout is `isPinned: false`, so the menu should
close like other list-only actions.

## Approach
- Option 1 (I chose this one): Derive close behavior from
`item.isPinned` -> pinned commands keep the menu open; non-pinned ones
close it.
- Option 2 (not chosen): remove the engine command override totally and
use the default close behavior for all commands.

Option 1 is more targeted: it fixed Edit Layout without changing pinned
commands (e.g. Export progress in the menu list). Option 2 is simpler
but widens the blast radius to every engine command clicked from the
side panel list.

## Screenshots
### Before

https://github.com/user-attachments/assets/70b8dc75-af00-4917-81a1-646381f571d5

### After

https://github.com/user-attachments/assets/f733d696-9330-4e6c-993b-8a8133c53e0d

---------

Signed-off-by: Parship Chowdhury <parshipchowdhury@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-19 11:21:50 +00:00
Félix Malfait 5674f693d7 fix: prevent Create Workspace redirect from being cancelled (#21835)
## Summary

Clicking **Create Workspace** in the multi-workspace dropdown did
nothing.

The handler closed the dropdown right before redirecting:

```ts
const createWorkspace = () => {
  closeDropdown(MULTI_WORKSPACE_DROPDOWN_ID); // unmounts this component
  redirectToDefaultDomain({ ... });           // schedules window.open ~1ms later
};
```

`redirectToDefaultDomain` → `useRedirect` wraps the navigation in
`useDebouncedCallback(..., 1)`. `closeDropdown` flips the dropdown
content to `{isDropdownOpen && ...}` → `false`, unmounting
`MultiWorkspaceDropdownDefaultComponents` — the component that owns that
debounced callback. `use-debounce` drops the pending call on unmount, so
the queued `window.open` never fires. React commits the unmount before
the 1ms timer, so it loses every time. Regression from #21723, which
added the `closeDropdown` call.

## Fix

Remove the `closeDropdown` call. The redirect navigates the whole page
away, so closing the dropdown first is unnecessary — and it mirrors the
sibling "switch workspace" handler, which already redirects without
closing.

## Why not reorder, or drop the debounce?

The 1ms debounce in `useRedirect` is intentional (#9079, "sleep before
redirect"). Callers set cookie-backed state immediately before
redirecting — e.g. `redirectToDefaultDomain` clears the
`lastAuthenticateWorkspaceDomain` cookie via `useCookieStorage`.
Deferring the hard navigation by one macrotask lets that cookie write
flush before the page tears down; removing it risks dropping the write.
Reordering wouldn't help either, since the unmount still beats the
timer. So the debounce is left untouched.

## Logout is not affected

`signOut` → `clearSession` navigates with `window.location.assign(...)`
directly (synchronous, not debounced) and never calls `closeDropdown`,
so it can't hit this race.

## Testing

- Before: clicking Create Workspace → `window.open` called 0 times, page
unchanged.
- After: navigates to
`<defaultDomain>/welcome?action=create-new-workspace` and renders the
"Create your workspace" form.
- Switch-workspace and Log out both still work.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21835?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. -->
2026-06-19 12:57:06 +02:00
Thomas Trompette 4075018834 fix(workflow): label manual trigger record output as Record/Records (#21832)
## What

The manual trigger output schema exposed the triggering record(s) under
a node labeled **Payload**. Relabels it to match what the node actually
contains:

- **Single-record** availability → **Record**
- **Bulk-records** availability → **Records**

## Why

"Payload" was a misnomer — the node holds the record(s) that triggered
the workflow. This is a display-label-only change.

## Notes for reviewers

- **No migration.** The persisted output schema key stays `payload`, so
existing variable references (`{{trigger.payload.x}}`) are unaffected.
- The front recomputes the output schema on the fly
(`computeStepOutputSchema`), so the variable picker shows the new labels
immediately, including for existing triggers.
- The backend (`workflow-schema.workspace-service`) is updated to match
for newly persisted/re-saved schemas. Previously persisted schemas keep
"Payload" until re-saved.
- Added `WORKFLOW_TRIGGER_RECORD_LABEL` /
`WORKFLOW_TRIGGER_RECORDS_LABEL` and removed the now-unused
`WORKFLOW_TRIGGER_PAYLOAD_LABEL`.
- Unit tests updated for both single and bulk cases (55/55 passing).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21832?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. -->
2026-06-19 12:36:14 +02:00
Thomas Trompette ceecae30db fix(workflow): show fields for system objects in record-updated trigger (#21826)
## Problem

On the **Record is updated** (and **upserted**) workflow trigger,
picking a record type under the **Advanced** submenu (i.e. a *system*
object) showed an empty "Fields (Optional)" list — you couldn't select
any field to watch.

## Root cause

The trigger's field picker (`WorkflowFieldsMultiSelect`) was called with
`actionType="UPDATE_RECORD"`, which runs each field through
`shouldDisplayFormField`. For `UPDATE_RECORD` that predicate requires
`(isUIEditable ?? true)` — correct for the *Update Record action* (you
can't write to a read-only field), but wrong for a *trigger*, where
you're choosing which fields to **watch for changes** and editability is
irrelevant.

System objects define their fields with `isUIEditable: false`, so every
field failed the gate and the list rendered empty.

## Fix

Add DATABASE_EVENT trigger type to separated from action type.

The `UPDATE_RECORD` / `UPSERT_RECORD` action paths are untouched.
2026-06-19 09:48:14 +00:00
Félix Malfait adf6eb572b feat(billing): embed Stripe Payment Element in onboarding (#21759)
## What & why

Replaces the hosted Stripe Checkout redirect on the onboarding "Choose
your plan" step (credit-card trial) with an inline Stripe **Payment
Element**, so users never leave the app to enter card details.

## How it works

- **Frontend:** a deferred `<Elements mode="setup">` renders the Payment
Element, themed via the Appearance API. On Continue: `elements.submit()`
→ `checkoutSession` mutation creates the trialing subscription
server-side and returns its pending SetupIntent `clientSecret` →
`stripe.confirmSetup()` confirms the card (handling 3DS) → redirect to
the existing `/plan-required/payment-success`.
- **Backend:** new `BILLING_STRIPE_PUBLISHABLE_KEY` config var exposed
via `/client-config`; the card path creates the subscription with
`payment_behavior: default_incomplete` + a free trial (so Stripe
attaches a `pending_setup_intent`) and returns its client secret. The
hosted-Checkout code path is removed.
- The **no-credit-card** trial path is unchanged.
- Billing address collection is **disabled** in the Payment Element to
reduce friction; `automatic_tax` is correspondingly disabled (tax needs
an address — collect it later, e.g. at conversion / via the billing
portal).

## Required before this works
1. Set `BILLING_STRIPE_PUBLISHABLE_KEY` (`pk_…`) on the server (infra
change pending).
2. Run `nx run twenty-front:graphql:generate --configuration=metadata`
against a server exposing the updated schema (see inline note on the
hand-authored document).
3. Verify in Stripe test mode: happy path, 3DS (`4000 0025 0000 3155`),
a decline.

## Verified
typecheck (front + server), oxlint + oxfmt clean,
`client-config.service.spec` passing. Not run here: the app end-to-end /
Stripe test mode and `graphql:generate` (no server/DB in the dev
container).

I've left self-review comments inline flagging cleanup opportunities
plus a couple of architectural/tech-debt items.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01TxCfinXq7abSrbF7aTw2cA

---
_Generated by [Claude
Code](https://claude.ai/code/session_01TxCfinXq7abSrbF7aTw2cA)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21759?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. -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-19 11:40:55 +02:00
Harsh Singh 1e744a761d fix: reorder table columns against visible fields only (#20940) (#21084)
## Fixes #20940

### Problem
The "Move Left" / "Move Right" actions in the table column header menu
were unreliable. Clicking them often produced no visible change, or
appeared to move the column an inconsistent number of positions.

### Root cause
`useMoveRecordField` computed the swap target from **all** record fields
(`currentRecordFieldsComponentState`) sorted by position — including
hidden and non-readable columns. As a result, a move frequently swapped
positions with an *invisible* neighbor, leaving the visible column order
unchanged.

This was also inconsistent with the drag-and-drop reorder path
(`useReorderVisibleRecordFields`), which already operates only on the
visible field set, and with the dropdown's own Move enable/disable
logic, which is based on `visibleRecordFields`.

### Fix
`useMoveRecordField` now sources the neighbor from
`visibleRecordFieldsComponentSelector` — the same selector that drives
the table display and the Move menu items (`isVisible && isReadable &&
isActive`, sorted by position). The real `position` values are still
swapped, so hidden columns keep their positions and only the visible
order changes.

### Tests
Added `useMoveRecordField.test.tsx`, which seeds real object metadata
with a hidden column interleaved between visible ones (by position) and
asserts that the visible selector reorders correctly after a move. The
test fails against the previous implementation and passes with this
change.

### How to verify
1. Open any table view.
2. Open a column header menu and click "Move Right" / "Move Left".
3. The column now moves reliably by one visible position each click,
regardless of hidden columns.

---------

Co-authored-by: Harsh Singh <harsh@Harshs-MacBook-Air.local>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-19 11:26:52 +02:00
Parship Chowdhury c32cb78562 fix: filtered view resetting to unfiltered list on navigation (#21080)
## Summary
- Resolves #21079 
- Object navigation links no longer force the default index view, which
had no saved filters.
- Returning to an object after “Save as new view” now opens the last
visited (filtered) view instead of the unfiltered default list.

## Test plan
1. Add a filter -> save as new view -> list is filtered
2. Navigate away and back -> filters still applied (not reset to
unfiltered)

## Screencast
### Before

https://github.com/user-attachments/assets/25326339-a3a1-4171-89cc-5149e254982e

### After

https://github.com/user-attachments/assets/b4600043-fc7a-4670-9c68-23daa6c31ec8

---------

Signed-off-by: Parship Chowdhury <parshipchowdhury@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-19 10:40:14 +02:00