Commit Graph

6471 Commits

Author SHA1 Message Date
github-actions[bot] 0ce90d5c82 i18n - translations (#23116)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23116?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: github-actions <github-actions@twenty.com>
2026-07-21 17:13:07 +02:00
Paul Rastoin 71a1ff7ac8 Cache twenty-client-sdk modules host-side via content-addressed URLs (#22981)
## Context

Front component sources are fetched host-side and integrity-verified by
the SHA-256 checksum embedded in their URL, cached in Cache Storage — a
layer that exists specifically because their download URLs are presigned
and rotate. The `twenty-client-sdk` modules (`core` and `metadata`) were
re-fetched on every render and could not be cached safely: their URLs
carried no checksum and the server exposed no freshness signal.

This PR makes the SDK module URLs **content-addressed** and relies on
the **browser HTTP cache** for immutability, and it keys the checksums
on their real owners: the **application** for `core`, the **instance**
for `metadata`. The checksum does double duty: cache invalidation
(regeneration changes the checksum → the URL changes → guaranteed cache
miss) and a server-side cacheability guard (the server only grants
`immutable` when the checksum in the URL matches the authoritative
checksum it knows for that module — persisted at generation time for
`core`, hashed once at bootstrap for `metadata` — so no per-request
hashing of the served bytes). Note this is **not** an end-to-end
integrity guarantee: there is no client-side hash verification, and on a
fingerprint mismatch the server still serves the current bytes with
`no-store` (self-healing for stale URLs) rather than failing.

<img width="2412" height="926" alt="image"
src="https://github.com/user-attachments/assets/d97935d2-0fdb-4c44-89ac-596b7ca8ca64"
/>

Closes twentyhq/core-team-issues#2688.

## Routes

| Module | URL | Scope |
| --- | --- | --- |
| `core` | `/rest/sdk-client/{applicationId}/core[/{checksum}]` | Per
application (generated bundle) |
| `metadata` | `/rest/sdk-client/metadata[/{checksum}]` |
**Instance-wide**: no application segment, so every application
converges on one URL and the browser downloads the module once per
release instead of once per application |

The previous application-scoped metadata path
(`/rest/sdk-client/{applicationId}/metadata[/{checksum}]`) is **kept for
backward compatibility**, new clients just stop generating those URLs.
The instance-wide route is declared before the parameterized route so
`metadata/{checksum}` is not swallowed as `:applicationId/:moduleName`.

## Caching model

| Request | `Cache-Control` | Effect |
| --- | --- | --- |
| Fingerprinted URL, checksum matches the known module checksum |
`immutable` | Cached indefinitely by the browser HTTP cache; a new
checksum is a new URL |
| Fingerprinted URL, checksum does not match | `no-store` | Current
bytes served uncached (self-healing for stale URLs) |
| Bare URL (pre-generation fallback, `core` only in practice) |
`no-store` | Never cached |

- Both responses also set `X-Content-Type-Options: nosniff` and
`Content-Type: application/javascript`.
- SDK modules are intentionally **not** placed in Cache Storage. That
layer stays reserved for the presigned/rotating component-source URLs;
SDK modules are served directly and authenticated, so the browser HTTP
cache (keyed by the content-addressed URL) is their single cache layer.

## Checksum provenance

- **core** — per **application**, persisted on
`application.sdkClientCoreChecksum` at generation time and read back
from `flatApplicationMaps` (never re-hashed per request).
- **metadata** — **instance-wide**, hashed once from the installed
`twenty-client-sdk/dist/metadata.mjs` package (warmed at bootstrap,
memoized per process) and served straight from that package, so it is
fresh from the first request after a release with no archive dependency.

## Server (twenty-server)

- Hash `dist/core.mjs` at SDK generation and persist
`sdkClientCoreChecksum` via `applicationRepository.update`. Adds the
nullable text column to `application.entity.ts` (mirroring
`packageJsonChecksum`) plus a fast instance command with up/down;
`FlatApplication` picks it up automatically.
- New **application-scoped** query
`applicationSdkClientChecksums(applicationId: UUID!):
SdkClientChecksums` on `ApplicationResolver` (metadata schema,
`WorkspaceAuthGuard` + `NoPermissionGuard`). `SdkClientChecksums.core`
is **nullable** and stays `null` until the SDK has been generated at
least once; `metadata` is **always present** (bootstrap-warmed), so the
metadata module is cacheable from the very first render of any app. The
query itself returns `null` only for unknown applications.
- `SdkClientChecksumsDTO` now lives in the shared
`core-modules/sdk-client/dtos/`. `FrontComponentDTO` and the
`frontComponent` resolver no longer carry checksums (decoupled from the
front-component row).
- `sdk-client` controller: instance-wide `metadata[/:checksum]` route
(no workspace-cache or application lookup, serves the memoized installed
module) + application-scoped `:applicationId/:moduleName[/:checksum]`
route (serves `core` from the per-application archive, `metadata` kept
for back-compat). Cacheability compares the URL checksum against the
**known** checksum — persisted `sdkClientCoreChecksum` for `core`,
memoized package hash for `metadata` — instead of hashing the served
bytes on every request: `immutable` on match, `no-store` otherwise (bare
URL or stale fingerprint), plus `nosniff`. A persisted checksum out of
sync with the archive only downgrades to `no-store` until the next
regeneration.

## Front (twenty-front)

- New metadata query `GetApplicationSdkClientChecksums`, keyed by
`applicationId`; removed the `sdkClientChecksums` selection from
`FindOneFrontComponent`.
- `getSdkClientUrls` builds the two module URLs independently:
`/sdk-client/{applicationId}/core/{checksum}` and the **instance-wide**
`/sdk-client/metadata/{checksum}` (no application segment → one shared
browser cache entry per release across all applications). Each falls
back to its bare URL when its checksum is absent — since `core` is
nullable, a never-generated app still gets a content-addressed metadata
URL and only `core` falls back. The checksum type is sourced from the
codegen `SdkClientChecksums` type rather than a hand-maintained
duplicate.
- `FrontComponentRenderer` is split into a gating outer component (runs
`FindOneFrontComponent`, renders nothing while loading) and a content
component that receives a guaranteed-non-null `frontComponent`.
Following project conventions, the side effects live in dedicated effect
components: `FrontComponentLoadErrorSnackBarEffect` (query error →
snackbar) and `FrontComponentApplicationTokenPairEffect` (mirrors the
query-derived token pair into component state unconditionally, `null`
included, so revoked credentials can never be retained or refreshed).
The content component fetches checksums via the application-keyed query
and **gates the mount of SDK-using components on that query**, so the
very first module fetch is always the content-addressed (`immutable`)
URL instead of the bare `no-store` one. Non-SDK components skip the
query and are never blocked.
- **Live invalidation without reload:** SDK regeneration updates the
application row, and the server broadcasts an `application` metadata
event carrying the new core checksum.
`useOnApplicationSdkClientChecksumsUpdated` /
`useUpdateSdkClientChecksumsApolloCache` patch the application-keyed
checksum query cache (core only; the instance-wide metadata is
preserved), so every mounted component of that application picks up the
new URL at once. This replaces the previous frontComponent-derived field
and closes the earlier "known gap" (a mounted component staying on a
session-old checksum until a full reload). The cache-patching callback
is memoized (`useCallback`) so the window listener is registered once
per application, and the listener is **skipped entirely** for non-SDK
components (`useListenToMetadataOperationBrowserEvent` gained a `skip`
option) — they register no listener and never refetch a query they don't
consume.

## Renderer (twenty-front-component-renderer)

- SDK sources are fetched through a dedicated plain authenticated fetch,
`fetchJavaScriptModuleSourceText` (Bearer header, `credentials:
'omit'`), instead of the Cache Storage `fetchComponentSource` path;
`fetchSdkClientSources` uses it. Execution stays exclusively in the
opaque-origin worker via blob URLs; the host only fetches and forwards
source strings (no hashing host-side). Staleness self-resolves through
the checksum: new checksum → new URL → cache miss.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22981?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-07-21 15:05:00 +00:00
Etienne 8f704e87e3 fix(ai-chat) - fix AI record references when display names contain markdown characters (#23113)
<img width="516" height="86" alt="Screenshot 2026-07-21 at 16 41 16"
src="https://github.com/user-attachments/assets/6e14b933-b48e-49ab-856b-400efdeba53a"
/>




## Summary
- Switch record references from `[[record:object:id:label]]` to
`[[record:object:id:label[[/record]]` so labels can include `]`,
backticks, brackets, and other markdown-significant characters
- Parse references with an explicit close tag (still accepting legacy
`]]`), escape labels before markdown lexing, and serialize mentions
through a shared formatter
- Update the AI chat system prompt so the model emits the new format

## Test plan
- [ ] Ask AI about a record whose name contains `` ` ``, `[`, `]`, or
`]]` and confirm it renders as a chip, not broken markdown
- [ ] Confirm legacy `[[record:...]]` references still chip correctly
- [ ] Mention a record in the chat editor and verify serialized text
uses `[[/record]]`
- [ ] Run:
- `npx jest src/modules/ai/utils/__tests__/findRecordReferences.test.ts
src/modules/ai/utils/__tests__/formatRecordReference.test.ts
src/modules/ai/utils/__tests__/protectRecordReferencesForMarkdown.test.ts
src/modules/ai/components/__tests__/TextWithRecordLinks.test.tsx
--config=packages/twenty-front/jest.config.mjs`
  - mention extension tests for `MentionTag` / `MentionSuggestion`

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23113?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-07-21 17:00:29 +02:00
github-actions[bot] d1087d5fc7 i18n - translations (#23114)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23114?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: github-actions <github-actions@twenty.com>
2026-07-21 16:30:12 +02:00
martmull e14c32015f Make upgrade applications batch size a job parameter defaulting to 5 (#23101)
Makes the batch size used when upgrading applications a parameter
instead of a hardcoded constant, defaulting to 5, and lets admins set it
from the upgrade confirmation modal.

Backend:
- `UpgradeApplicationsJobData` gains an optional `batchSize` field,
passed through by `UpgradeApplicationsJob` to the service.
- `ApplicationUpgradeService.upgradeAllApplications` accepts an optional
`batchSize` parameter, defaulting to
`UPGRADE_APPLICATIONS_DEFAULT_BATCH_SIZE = 5` (previously a fixed batch
size of 20). The value is sanitized to a positive integer to avoid an
infinite batching loop.
- The `upgradeRegistrationApplications` admin mutation accepts an
optional `batchSize: Int` argument and forwards it to the job.

Frontend (admin panel):
- The "Upgrade existing installations" confirmation modal now includes a
"Batch size" number input, defaulting to 5, sent with the mutation.
- Updated the admin GraphQL document and generated types.

## Screenshots

Upgrade section on the admin app registration page:

![Upgrade
section](https://raw.githubusercontent.com/twentyhq/twenty/8f6f7b5b87b8218e10f9f8ed8a8b705cf25f22f6/upgrade-section.png)

Confirmation modal with the new batch size input (defaults to 5):

![Upgrade confirmation modal with batch size
input](https://raw.githubusercontent.com/twentyhq/twenty/8f6f7b5b87b8218e10f9f8ed8a8b705cf25f22f6/upgrade-modal-batch-size.png)

---------

Co-authored-by: Martin <martin@twenty.com>
2026-07-21 14:22:19 +00:00
github-actions[bot] 64891c561d i18n - translations (#23108)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23108?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: github-actions <github-actions@twenty.com>
2026-07-21 15:48:30 +02:00
Félix Malfait 3ad3e8bd1a feat: kanban, calendar and group-by table layouts for dashboard view widgets (#22963)
## Context

Dashboard view widgets previously only rendered flat tables. This PR
ships the full feature: **Table with group-by**, **Kanban**, and
**Calendar** layouts for dashboard view widgets — server API + frontend,
end-to-end. (Originally staged as a 4-PR stack — #22966, #22967, #22968
— consolidated here per review.)

## Server / API

- **View typing.** Adds `KANBAN_WIDGET` and `CALENDAR_WIDGET` to
`ViewType` (following the `TABLE_WIDGET` precedent) so widget-backing
views keep their layout in `view.type` while staying excluded from
record-index pickers. Shared `getViewLayoutFromViewType()` maps widget
types to their base layout; `isWidgetViewType()` centralizes the
exclusions that were previously hardcoded per-site.
- **Migrations.** Two fast instance commands (**2.23**): `ALTER TYPE
core.view_type_enum ADD VALUE` for both values, and a widened
`CHK_VIEW_CALENDAR_INTEGRITY` constraint covering `CALENDAR_WIDGET`
(entity `@Check` updated for fresh installs).
- **Validation.** `FlatViewValidatorService` keys kanban/calendar
validation on the mapped layout, so widget views get the same invariants
as index views (kanban needs a groupable group-by field; calendar needs
a date field + layout). Calendar widget views default to month; a
non-month (DAY/WEEK) layout is rejected at the API level **unless** the
`IS_CALENDAR_WEEK_VIEW_ENABLED` feature flag is enabled for the
workspace — the same flag that gates day/week on index calendars.
- **API.** `upsertViewWidget` (LAYOUTS permission) accepts a nested
`view` settings input (`type`, `mainGroupByFieldMetadataId`,
`shouldHideEmptyGroups`, kanban aggregate/column-width, calendar
layout/fields). Routes through the standard update path, so `viewGroups`
auto-generate from SELECT options exactly like index views. Only widget
view types accepted; only `RECORD_TABLE` widgets can change view
settings.
- **AI tools.** `create-complete-dashboard` + `create_view` now
use/allow the `*_WIDGET` types (previously they created plain `TABLE`
views that leak into index pickers).

## Frontend

**Settings panel.** The **Source** (object) row comes first, since which
layouts are available depends on it. The **Layout** row below is a
working dropdown (Table / Kanban / Calendar); layouts the source object
can't support are **disabled with a hint** ("Needs a Select field" /
"Needs a Date field") rather than hidden. Group-by row (select fields;
searchable) with a **Hide empty groups** toggle while grouped; **Date
field** row replaces Group by while Calendar is active, and — when the
`IS_CALENDAR_WEEK_VIEW_ENABLED` flag is on — a **Calendar view** row
(Day / Week / Month) appears beside it; **Limit** row hidden while
grouped (only the flat virtualized loader enforces it). Kanban keeps its
group-by locked (no `None` option).

**Instant edit-mode preview.** Draft snapshots carry `viewGroups`;
picking a group-by synthesizes them client-side
(`buildDraftViewGroupsForFieldMetadataItem`, mirroring the server&#39;s
generation), so grouped tables/boards preview immediately before
dashboard save. On save, `upsertViewWidget` responses hand back the
server-generated groups, which replace the client-generated ones in the
persisted snapshot.

**Renderers.** `RecordTableWidgetRendererContent` branches on the
backing view&#39;s layout: `RecordBoardWidget` (wraps the standard
`RecordBoardContainer`) and `RecordCalendarWidget` (mounts the existing
`RecordCalendar`, which renders month / day / week) inside the same
per-widget provider sandbox the table uses.

**Read-only semantics.** Two flags with distinct scopes, each documented
on its state:
- `isRecordBoardViewSettingsReadOnlyComponentState` — locks the board
chrome that edits view settings (add group, column reorder/resize/menu,
aggregates); **card drag still updates records** under object
permissions.
- `isRecordCalendarReadOnlyComponentState` — widget calendars are
read-only by default (no drag, no add-new, no in-calendar layout
switch); cards open the side panel. The one exception, behind
`IS_CALENDAR_WEEK_VIEW_ENABLED`: a **live (non edit-mode) day/week**
widget calendar allows drag-to-reschedule and record creation under
object permissions. Month calendars and edit-mode previews stay
read-only.

**Calendar state componentization.** The calendar module&#39;s three
settings move from global atoms to component states keyed on
`RecordCalendarComponentInstanceContext` (same pattern as record-board),
so several calendar widgets and an index-page calendar can coexist
without leaking state. All readers resolve the ambient instance;
calendar unit tests updated.

**Multi-instance fixes that also fix index pages:** record drag states
were written against a different instance than every reader resolves
(now use the ambient instance); the board sticky-header DOM id is
namespaced per board; dragged board cards portal to `document.body`
while dragging so react-grid-layout&#39;s transforms can&#39;t offset
the clone from the pointer.

## Scope (v1)

- Widget calendars are month-only and read-only by default. With
`IS_CALENDAR_WEEK_VIEW_ENABLED` enabled, day/week layouts become
selectable (UI + API) and live day/week widget calendars support
drag-to-reschedule and record creation under object permissions.
- Widget group-by offers SELECT fields only (server auto-generates
groups from options; widgets have no per-record add-group flow).

## Tests

- Integration: `upsert-view-widget-view-settings.integration-spec.ts` (9
tests — group auto-creation, invalid type/field rejections, non-month
calendar widget rejected while the week/day flag is off and accepted
once it&#39;s enabled, combined settings+fields call); pre-existing
`upsert-view-widget` suite (20) green.
- Front: new suites for draft view-group generation and snapshot
clone/build utils; calendar suites componentized; full `twenty-front`
jest, typecheck, oxlint green; `twenty-server` typecheck + lint green.
- Browser-verified end-to-end (real dev server + seeded workspace):
configure → live edit-mode preview → save → reload for all three
layouts; measured drag with pointer inside the card; index-page calendar
re-verified (with the week/day flag enabled).

https://claude.ai/code/session_01E5N87kwwZWhDtEQaP72cMf
2026-07-21 15:41:08 +02:00
Marie bc3112a999 Fix: allow API key creation without Roles permission (#23102)
## Problem

A user with the **API keys & webhooks** permission but **without** the
**Roles** setting permission cannot create an API key through the UI.
The role selector relies on the `getRoles` query, which is guarded by
the `ROLES` permission, so the roles list comes back empty,
`SettingsDevelopersRoleSelector` early-returns, and no role can be
selected — leaving the form unsavable.

<img width="1058" height="408" alt="Screenshot 2026-07-21 at 13 38 34"
src="https://github.com/user-attachments/assets/fe97ba78-e116-458d-af10-11c5969c4636"
/>

## Fix

Expose the assignable roles through the API-key permission scope so
users can **pick** a role to assign to an API key without being able to
**edit** roles.

- **Backend**: add `getApiKeyRoles` query on `ApiKeyResolver` (already
guarded by `API_KEYS_AND_WEBHOOKS`), backed by
`ApiKeyRoleService.getApiKeyAssignableRoles` which returns roles where
`canBeAssignedToApiKeys = true`.
- **Frontend**: add a `GetApiKeyRoles` query and use it in the API key
create and detail pages instead of `getRoles`. The role selector prop
type is narrowed to the fields it actually uses.

<img width="1025" height="455" alt="Screenshot 2026-07-21 at 13 45 01"
src="https://github.com/user-attachments/assets/f1be8f97-5a30-4afc-9eee-c928f4607471"
/>
2026-07-21 13:31:05 +00:00
Etienne 6d38b14520 fix(ai-chat) - fix record chips in AI ask-questions card (#23106)
## Summary
- Ask-questions cards rendered question text and option labels as plain
strings, so `[[record:...]]` showed up raw instead of as chips
- Extracted `TextWithRecordLinks` from `LazyMarkdownRenderer` and reuse
it in `AiChatQuestionCard` for question text and option labels
- Added unit coverage for plain text, single, and multiple record
references

<img width="532" height="242" alt="Screenshot 2026-07-21 at 14 37 43"
src="https://github.com/user-attachments/assets/a4bbd386-7757-4f09-a74d-c7eb3d0f74b9"
/>


## Test plan
- [ ] Open an AI chat ask-questions card whose question/options include
`[[record:...]]` mentions
- [ ] Confirm mentions render as record chips (not raw markup)
- [ ] Confirm normal assistant text replies still chip mentions as
before
- [ ] Run `npx jest
packages/twenty-front/src/modules/ai/components/__tests__/TextWithRecordLinks.test.tsx
--config=packages/twenty-front/jest.config.mjs`


fixes:
https://discord.com/channels/1130383047699738754/1526887613867360347

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23106?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-07-21 15:07:32 +02:00
github-actions[bot] ea5f908cb2 i18n - translations (#23085)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23085?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: github-actions <github-actions@twenty.com>
2026-07-20 19:08:21 +02:00
Etienne 9abf76f5f2 fix(ai): show answered ask_questions as a card in chat history (#23075)
## Context

Feedback on the `ask_questions` (Ask AI) tool:
- When answering a select prompt with a free-form message, the answer
wasn't surfaced as expected in the conversation history.
- The selected value also looked dropped once picked.

Root cause: answered `ask_questions` parts were caught by the
thinking-steps grouping and rendered as a generic collapsible "Ran
ask_questions" tool step (JSON output), so the dedicated renderer was
never reached.

## Changes

- **Render answered questions as a card**
(`AiChatQuestionStatusRenderer`): an "Answers" card that shows each full
question with the chosen option label(s) or the free-text answer beneath
it, instead of the faint inline `header: value` line.
- **Free-text keyboard navigation** (`AiChatQuestionCard`): pressing
Enter in the free-text area now advances to the next question, or
submits when on the last question (mirroring the option-select flow).
Shift+Enter still inserts a newline.

## Notes

- No schema/GraphQL changes; display + interaction only.
- Existing `thinkingStepsDisplayState` grouping test is unaffected (only
`web_search`/`create_task`/`code_interpreter` are used there).


<img width="421" height="301" alt="Screenshot 2026-07-20 at 17 48 04"
src="https://github.com/user-attachments/assets/3845055d-7061-40c0-b263-aa1c670329cd"
/>


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


fixes
https://discord.com/channels/1130383047699738754/1526871110300209282
2026-07-20 18:53:51 +02:00
Thomas Trompette 05132d262b fix(workflow): show account select in Send Email node when no account is connected (#23066)
# Why

In the workflow **Send Email** (and Draft Email) node, when the
workspace has no eligible connected account, the **Account** field
disappears entirely — only the variable picker icon remains. The "Add
account" call-to-action is unreachable, so there is no way to connect an
account from the node.

## Root cause

Regression from #21075. `FormSelectFieldInput` used to always pass a
default empty option ("No Account") to `<Select>`; since #21075 it only
prepends it when `isNullable` is set, and the email account field
doesn't set it. With zero connected accounts, `<Select>` then has no
option to resolve a selected option from and bails out rendering an
empty fragment — even though a `callToActionButton` is configured:

```tsx
// Select.tsx
if (!isDefined(controlSelectedOption)) {
  return <></>;
}
```

# What changed

`FormSelectFieldInput` now prepends the empty option whenever the field
is nullable **or there are no options at all**. A populated non-nullable
select still offers no clearing choice (the #21075 behavior is
preserved); an empty one renders its "No X" state so the control — and
its call-to-action — stay visible and clickable.

# Test plan

- New story `FormSelectFieldInput > NoOptionsWithCallToAction`: zero
options + CTA renders the "No Work Policy" control, the dropdown opens,
and the CTA is clickable.
- New story `WorkflowEditActionEmailBase > NoConnectedAccounts`: with
`MyConnectedAccounts` mocked to `[]`, the Account field renders "No
Account" instead of vanishing. The meta's msw handlers move to the
keyed-object form so the story can override a single query (story-level
handler arrays get concatenated after the meta's, and msw's first match
wins), and the story evicts the module-singleton Apollo client's cached
accounts so it actually hits the empty mock.
- `npx nx typecheck twenty-front` and `npx nx lint:diff-with-main
twenty-front` pass; both story files pass under the storybook vitest
project (13 stories total).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23066?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-07-20 16:53:29 +00:00
Paul Rastoin 1be5a0e54a System side effect relations (#22882)
Closes twentyhq/core-team-issues#2667

## What

Default relations to the standard relation objects
(`timelineActivities`, `attachments`, `noteTargets`, `taskTargets`) are
now fully owned by the **metadata side-effect engine**. Neither the API
transpilers nor the SDK manifest builder provision them anymore: any
object creation, rename or deletion — regardless of the caller — goes
through the same engine handlers.

## Why

- Provisioning was duplicated across the API path and the SDK manifest
builder, with diverging behavior.
- Universal identifiers of relation fields were derived from object
**names**, so renaming an object mutated them and forced lossy
delete+create cycles on manifest sync.

## How

### Engine-owned lifecycle (side-effect handlers)

- `objectSystemRelationsOnCreate`: provisions the 8 forward/reverse
relation fields (+ join column indexes) when an object is created.
- `objectSystemRelationsOnUpdate`: renames the reverse morph fields
(`target<ObjectName>`) when their host object is renamed — a lossless
`fieldMetadata.update`.
- `objectSystemSideEffectsOnDelete`: cascades deletion of engine-owned
fields/indexes when the object is deleted.
- The API transpilers and the SDK `buildManifest` no longer inject these
fields; `isSystemSideEffect: true` marks engine-owned entities, guarded
by a granular property allowlist (only `isActive` is user-editable) and
excluded from manifest deletion inference.

### Name-free deterministic universal identifiers

New `getSystemRelationFieldUniversalIdentifier({
applicationUniversalIdentifier, objectUniversalIdentifier,
relationTargetObjectUniversalIdentifier })` in `twenty-shared`, exported
from `twenty-sdk/define`. The identifier is keyed on the two **object**
identifiers instead of field names (direction encoded by argument
order), so object renames never mutate relation field identifiers. It
cannot collide with the name-based `getFieldUniversalIdentifier`
derivation (field names cannot contain `:`).

### twenty-standard re-owned

All 48 forward/reverse system relation field declarations in
`STANDARD_OBJECTS` now pin the derived name-free identifiers (computed
inline via the shared util) and carry `isSystemSideEffect: true`, with
labels/icons declared explicitly (translated via `msg`).
`twenty-standard` is projected as if the engine had generated these
fields itself.

### 2.23 upgrade commands

- `reconcile-system-relation-field-universal-identifier`: structurally
matches existing default relation fields per workspace and backfills the
derived universal identifiers, `isSystemSideEffect` flags, and standard
labels/icons.
- `upgrade-people-data-labs-application`: upgrades installed PDL apps to
`1.0.7` right after the backfill to close the desync window (its views
reference the re-derived identifiers).

### Misc

- `people-data-labs` `1.0.7`: views temporarily pin the new derived
identifiers (TODO: import from the next released `twenty-sdk`).
- `UpgradeStatusModule` split out of `UpgradeModule` so the application
module cluster can consume upgrade status/migration services without
importing the versioned command bundles (fixes a require cycle that
crashed boot).
- Docs: `system-fields.mdx` documents the system relation fields and
their resolver; `sync-and-recovery.mdx` plan example no longer shows
auto-injected relations.

## Known red CI

`people-data-labs (dockerhub-latest)` fails by design until the 2.23
server image is published: the app pins the new identifiers which only
exist on a 2.23 server. The `local` leg (server built from this branch)
is green.

## System fields are no longer manifest-authorable (accepted regression)

The manifest converter no longer derives `isSystem` /
`isSystemSideEffect` from field names. Reserved-system-named manifest
fields (`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`,
`updatedBy`, `position`, `searchVector`) are now skipped at conversion
time when they carry the exact derived universal identifier (keeps
manifests built with older SDKs installable), and rejected with
`INVALID_INPUT` when they pin any other identifier. System fields are
therefore fully engine-canonical: nothing a manifest carries can produce
a system-flagged entity anymore.

**Accepted regression**: a manifest can no longer influence system field
properties at all. Previously a (legacy) re-declaration could shape them
at creation — which actually produced broken system fields, e.g. a
nullable, non-unique `id` — and could still toggle the allowlisted
`isActive` / `universalSettings` afterwards. We consider this acceptable
for now: per-app granularity over system fields will be reintroduced
later through the **override framework**, which will also settle update
semantics by forbidding direct updates over `isSystemSideEffect: true`
entities and expressing divergence as overrides.

`isSystemSideEffect`-only entities (the default relation fields
provisioned by this PR) still have no engine-level update guard (see
Follow-up below); that part is unchanged and also lands with the
overrides refactor.

## Follow-up

`isSystemSideEffect` field update/delete guards intentionally live at
the API layer (`sanitize-raw-update-field-input.ts`,
`from-delete-field-input-...util.ts`) rather than in the engine-level
`FlatFieldMetadataValidatorService`. Moving them into the validator
requires threading operation-origin (direct field mutation vs engine
cascade) through the migration matrix, otherwise legitimate object
rename/delete cascades (which carry `isSystemBuild=false`) would be
rejected. Tracked in twentyhq/core-team-issues#2671.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22882?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-07-20 18:53:24 +02:00
github-actions[bot] 4ebdecfdf0 i18n - translations (#23077)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-20 18:08:51 +02:00
Gautam pandit 3e14dcbb05 Fix record board column header action accessibility (#22496)
## Summary
- Keep Record Board column header actions mounted instead of rendering
them only on mouse hover
- Show actions on hover and focus-within so keyboard users can reach
them
- Avoid header layout shifts when actions appear

## Context
This is a small follow-up found while reviewing #22323. It does not
duplicate the Kanban column drag-and-drop implementation.

## Testing
- git diff --check
- Not run: package lint/typecheck because this checkout still has no
node_modules and Yarn is not available on PATH

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22496?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: bosiraphael <raphael.bosi@gmail.com>
2026-07-20 16:00:31 +00:00
github-actions[bot] 8a35f78d70 i18n - translations (#23076)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-20 17:51:00 +02:00
martmull 8d84a0b9f3 feat(app): allow non-admin developers to claim and list marketplace apps (#22621)
## Context

Follow-up to #22609. Lets a non-admin developer claim ownership of a
public Twenty app they published to npm, then request a marketplace
listing that a server admin reviews. Marketplace state is per-instance
for now.

## Claiming

- Developer tab gets a **Claim an application** section: look up an
unclaimed npm app by package name or universal identifier.
- Ownership is proven with GitHub OAuth against the package's npm
provenance (trusted publishing): the connected account must own the
GitHub account or organization the package was published from.
- Errors from the GitHub callback come back as a code and are shown
inline with a link to the relevant documentation.
- The old one-click claim stays admin-only.
- A **Sync catalog** button triggers a catalog refresh instead of
waiting for the hourly cron.
- Gated behind the `IS_APP_CLAIMING_ENABLED` feature flag.

## Listing requests

- Catalog-synced apps are created **unlisted**; a data migration unlists
previously auto-listed unclaimed npm apps (owned or vetted rows are left
untouched).
- Owners request a listing from the Distribution tab (logo + description
required); a server admin approves or rejects it from a **Listing
requests** section in the Admin Panel.

## Screenshots

<img width="1512" height="829" alt="image"
src="https://github.com/user-attachments/assets/788d4362-97c4-4e42-810c-ef1f11517bec"/>
<img width="1512" height="829" alt="image"
src="https://github.com/user-attachments/assets/d6246190-c82a-4f64-87be-3bb668527645"/>
<img width="1512" height="828" alt="image"
src="https://github.com/user-attachments/assets/21a8dad4-610b-4d1f-8948-b9acab40d373"/>
<img width="1512" height="829" alt="image"
src="https://github.com/user-attachments/assets/58246130-41f7-451e-ae7f-57bd21d04bb6"/>

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-07-20 15:43:40 +00:00
Raphaël Bosi 240e185323 Show connect emails step to invited users without granting credits (#23058)
Invited users never saw the connect-emails onboarding step, so they
could not connect their inbox while onboarding. Now they do, but only
the first user (the workspace creator) earns the import-contacts reward
for it.

The credit is gated on the workspace having a single member, reusing the
same "first user" signal the frontend already uses to gate the
invite-team step. The connect-account step is still claimed for everyone
so invited users' onboarding advances normally.

The frontend hides the "free credits" tag and the header counter bump
for invited users, so we do not promise credits they will not receive.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23058?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-07-20 17:00:39 +02:00
martmull 45b319ef2d Add autofocus to 2FA OTP inputs (#23067)
as title
<img width="854" height="533" alt="image"
src="https://github.com/user-attachments/assets/777c2d83-8318-4337-865d-67aebc9186c2"
/>
2026-07-20 14:29:19 +00:00
github-actions[bot] fd5afd00de i18n - translations (#23068)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-20 16:21:07 +02:00
martmull baa84bb2e0 Add auto-upgrade-in-apps (#23001)
We need to auto upgrade application, lets add this column in application
entity, and add an admin button to autoupgrade all applications to
latest app registrration version manually

<img width="1131" height="372" alt="image"
src="https://github.com/user-attachments/assets/4e755abc-38ad-4895-a2e8-d55ee1948ac2"
/>

<img width="906" height="533" alt="image"
src="https://github.com/user-attachments/assets/ce002057-0341-4581-bf9a-66ac2bd84a9b"
/>
2026-07-20 16:12:44 +02:00
Raphaël Bosi dd57842187 Show the onboarding welcome animation on sign-up only (#23057)
The welcome overlay replayed for existing users signing in, because it
had no signal for "this user just signed up" and inferred it from a
coincidence: a COMPLETED user standing on an onboarding URL while being
redirected away.

This drops that inference and triggers explicitly at the only two places
onboarding reaches COMPLETED: `useSetNextOnboardingStatus` and the
Stripe return.
2026-07-20 13:42:48 +00:00
github-actions[bot] dcd6683cac i18n - translations (#23007)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23007?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: github-actions <github-actions@twenty.com>
2026-07-17 22:48:12 +02:00
neo773 5bedd5b8cc feat(email-group): communications UX, per-record DNS status (#23002)
- Rename Communications label to singular, remove docs-home banner
- Provision unsubscribe Cloudflare records at domain creation and
surface per-record status badges; skip Cloudflare when not configured
- Move sending-domain status into the section header and only show the
records table when a record is unverified
- Reply to the original recipients when replying to your own message
- Fix DNS records table column/badge alignment; emit synthetic records
in the log driver for local testing

<img width="1496" height="849" alt="Screenshot 2026-07-17 at 7 47 24 PM"
src="https://github.com/user-attachments/assets/a5a59adb-2df4-4154-98b2-acf87a8008da"
/>
2026-07-17 22:41:25 +02:00
Félix Malfait 7e133a4930 Converge email recipient fields on existing patterns: shared parser/formatter, search-index members, one display-name rule (#22997)
# Why

Follow-up to #22668, addressing @charlesBochet's five post-merge review
comments. They all point the same direction: the recipient fields
rebuilt things the codebase already had. This PR converges on the
existing patterns where that holds up, and answers on the threads where
it deliberately does not.

# What changed, per comment

**Parser duplication
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064242))**:
`parseEmailAddressList` now lives in twenty-shared (addressparser, group
flattening, try/catch). The server's `safeParseEmailAddresses` delegates
to it, the front wrapper keeps only paste normalization (newlines to
commas) and invalid-token preservation for red chips. The
`addressparser` dependency moves from twenty-front to twenty-shared.
Side effect worth knowing: RFC 5322 group members in inbound To/Cc
headers were previously dropped entirely (group entries have no
top-level address, so the filter removed them); flattening now imports
those participants. Covered by a new regression test.

**Formatter duplication
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064243))**:
`formatEmailAddress` (quote only when specials require it) lives in
twenty-shared. The composer chips and the server's
`formatMessageFromHeader` both delegate to it. The Gmail From header
output is byte-identical: the name is mime-encoded first and encoded
words never contain characters that trigger quoting. CodeQL then caught
that the quoting (ported from the original front util) escaped quotes
but not backslashes, letting a crafted name close the quoted string
early; escaping now covers both as RFC 5322 quoted-pairs, with a
containment test proving a hostile name cannot split into extra
recipients on reparse.

**Member search divergence
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064231))**:
suggestions now search WorkspaceMember through the search index in the
same `useObjectRecordSearchRecords` call as Person (one ranked query),
and enrich hits from `currentWorkspaceMembersState`, exactly like
`SettingsRoleAssignmentWorkspaceMemberPickerDropdown`. The client-side
`filterBySearchQuery` pass is gone. The hook is now what the comment
described: the merge of context people, searched people, and members
into one ranked list, rendered with the same
`SelectableList`/`MenuItemAvatar` primitives the pickers use. Also fixed
while in there: searched person ids are sliced to the suggestion limit
before hydration, so top-ranked people can no longer be crowded out of
the hydration page.

**Chip resolution duplication
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064236))**:
the display-name preference is now one rule,
`getEmailIdentityDisplayName`, used by both
`getDisplayNameFromParticipant` (threads) and the composer chip/menu, so
the same address renders identically everywhere. The order is workspace
member, then person, then display name, then handle: when an address
belongs to both a teammate and a Person record, the internal identity
wins (product call from Felix). `BaseChip.maxLabelWidth` is renamed
`maxWidth` to match the twenty-ui `Chip` API. `ParticipantChip` itself
is not used inside the field: it renders a navigating `RecordChip` when
a person is linked, and navigation from the composer destroys the draft
(no draft persistence yet), plus the field chips need
remove/selected/danger/edit affordances it does not have.

**Rebuilding on MultiItemFieldInput
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064221))**:
answered on the thread rather than in code, deliberately.
`MultiItemFieldInput` is a dropdown-panel list editor (vertical rows,
one input at a time, bound to record-field contexts and
`FieldMetadataType`), and its own TODO says the API should be refactored
into a hook before growing. The inline wrapping chip row commits batches
(paste), dedupes with a flash, and keeps a persistent inline input with
suggestions; layering that through `renderItem`/`renderInput` would
strain both components. On the menu overlap: after comparing side by
side, the shared surface between `MultiItemFieldMenuItem`'s dropdown and
the chip menu is three `MenuItem` rows with different copy, order, and
neighbors; `MenuItem` is already the shared primitive, and a
config-driven fragment would be indirection without deduplication. If
deeper convergence is wanted, the honest path is the existing TODO
(extract the multi-item state machine into a hook, rebase both editors
on it); that touches the Links/Phones/Emails/Array/Files cell editors
and deserves its own PR.

# Verification

- New twenty-shared suites for the parser and formatter (16 tests),
including parse/format round-trips, the encoded-word case, and the
backslash-escaping containment case.
- Server messaging util specs all pass (70 tests), including new
group-flattening regression tests; From-header spec output unchanged.
- Front email module suites all pass (59 tests) with the slimmed
wrappers.
- Typecheck and lint green on twenty-shared, twenty-front,
twenty-server; oxfmt clean on all three.
- Playwright smoke against the seeded dev stack passes end to end:
context suggestions on the Google company, typed search showing people
and the workspace member row (now served by the search index), Enter
picking the top suggestion, duplicate merge, keyboard delete, chip menu
with clipboard copy, Ctrl+Enter committing the buffer then triggering
send.
2026-07-17 21:20:14 +02:00
Félix Malfait dc0bb7760f fix(front): only sign out when token renewal is rejected by the server (#22983)
## Context

Users are frequently signed out when coming back to Twenty. The console
shows `Failed to renew token after retries, triggering unauthenticated
error`: the access token has expired and the `renewToken` call fails.
Today any renewal failure wipes the stored token pair and redirects to
sign-in, even when the refresh token is still valid, for example when
the renewal request hits a transient network failure (laptop waking up,
VPN reconnecting) or a server restart during a deploy. Since the token
pair state is synced across tabs, one failing tab signs out every tab.

## What this does

- Only triggers the unauthenticated flow when the server definitively
rejects the refresh token. The `renewToken` mutation maps those cases to
`UNAUTHENTICATED` (expired or invalid JWT), `FORBIDDEN` (revoked) and
`BAD_USER_INPUT` (unknown or malformed token).
- Keeps the session on any other renewal failure (network errors after
retries, server errors): the token pair stays in place and the next
request triggers a fresh renewal attempt, so the session recovers once
the server is reachable again.
- Signs out immediately when the stored pair has no refresh token
instead of attempting a renewal that cannot succeed.
- Logs the renewal error, which was previously swallowed and made this
class of logouts hard to diagnose.

## Tests

- renews and replays the operation after an access token rejection
- signs out when the server rejects the refresh token
- keeps the session on a network error (asserts all retry attempts ran)
and on a server error
- signs out without attempting renewal when the stored pair has no
refresh token

Test mocks now reset between tests so per-test overrides cannot leak
into other tests.

[[Review in
cubic](https://www.cubic.dev/buttons/review-in-cubic-dark.svg)](https://cubic.dev/pr/twentyhq/twenty/pull/22983?utm_source=github)
2026-07-17 15:45:54 +02:00
github-actions[bot] c8f0b86316 i18n - translations (#22988)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22988?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: github-actions <github-actions@twenty.com>
2026-07-17 14:11:31 +02:00
Félix Malfait 5e27e04c0a Add mostly-empty field hints to data model settings (#22962)
## What

Fields that are empty in almost all records now show a subtle `Mostly
empty` hint next to their name in the object's Fields settings table
(same visual treatment as `Deactivated`), with a tooltip explaining the
signal and a matching **Mostly empty** toggle in the search filter
dropdown. The goal is to nudge admins to clean up and deactivate fields
nobody uses, while keeping the page untouched when the data model is
healthy.

## How

**No table scans.** Emptiness is read from Postgres planner statistics,
so the cost is a catalog lookup regardless of table size:

- `pg_class.reltuples` gates the feature on an approximate row count (≥
100 records; never-analyzed tables mean no hints). Reuses the shared
helper extracted from `ObjectRecordCountService`.
- `pg_stats.null_frac` plus the sampled frequency of the column type's
empty sentinel (`''` for text columns, `'{}'` for arrays, `'{}'`/`'[]'`
for json — matched per physical column type) gives a per-column empty
fraction. A value dominating ≥ 95% of a column is guaranteed to appear
in the most-common-values list, so the approximation is reliable exactly
at the threshold we care about.

**Decision rules** (pure util, unit-tested):

- Flag when every relevant column is ≥ 95% empty and the object has ≥
100 records.
- Skip system fields, the label identifier, relations, booleans, and
actor fields (exhaustive switch — a new `FieldMetadataType` fails to
compile until classified).
- Composite fields must have all their columns empty, with column sets
derived from `compositeTypeDefinitions`; only default-bearing code
columns (`currencyCode`, phone country/calling codes) are excluded so
stamped defaults don't mask emptiness.
- Anything unknown (missing stats, new column since last ANALYZE)
degrades to silence — no hint is ever shown on missing data.

**API:** one `mostlyEmptyFieldMetadataIds(objectMetadataId)` query on
the metadata schema, guarded by the `DATA_MODEL` settings permission,
fetched lazily when the fields page opens.

**UI:** exception-based — no new columns, no persistent controls. The
badge and the filter toggle only materialize when at least one field
qualifies, and disappear once things are cleaned up.

## Test

- Unit tests for the decision util (threshold,
system/label-identifier/inactive exclusion, missing statistics,
composite all-columns rule, links label/secondary data, currency
narrowing, excluded types).
- Catalog SQL validated against Postgres 16 with a table mimicking
Twenty's column shapes (text `''` defaults, enums, arrays, jsonb,
currency pairs), including the type-aware sentinel matching (a text
column full of literal `"{}"` strings does not count as empty).
- End-to-end on a seeded dev instance: 899 companies with a mix of
filled/empty fields — the API returned exactly the five fields predicted
by the raw statistics (`annualRevenue`, `employees`, `introVideo`,
`tagline`, `workPolicy`) and correctly excluded `address` (city 33%
filled), actor/system fields, and the label identifier.
- UI driven with Playwright: badge, tooltip copy, filter toggle, and
filtered table all verified visually.
- `lint:diff-with-main`, `typecheck` (server + front), and all three
`graphql:generate` configurations + SDK metadata client regenerated and
committed.
2026-07-17 12:03:11 +00:00
martmull 6360599943 Unify application version gate, registration writes and upgrade paths across sources (#22931)
Continues the source-unification arc after #22921. Three related
consolidations, one commit each.

## 1. Single semver version gate (`793f4849`)

The "incoming version must move forward" rule was hand-rolled twice:
workspace installs (validate semver, reject equal as
`APP_ALREADY_INSTALLED`, lower as `CANNOT_DOWNGRADE_APPLICATION`) and
tarball deploys (reject `lte` as `VERSION_ALREADY_EXISTS`).
`ApplicationVersionValidationService.validateVersionProgression` now
owns the comparison rules and messages; new maps in
`version-reason-to-exception-code.constant.ts` translate failure reasons
to each caller's existing exception codes, so error contracts observed
by the frontend/CLI are unchanged. A non-semver current version never
blocks, matching both previous behaviors.

## 2. One registration-metadata writer (`13eb5f91`)

Tarball upload and marketplace catalog sync wrote registration metadata
with their own repository calls, duplicating the gallery-image fileId
preservation and variable-schema sync, and bypassing the
per-registration lock and transaction that `updateFromManifest`
provides. Both now delegate to `updateFromManifest` (new
`additionalFields` allowlist for their extra columns: `tarballFileId`,
`isListed`, `isVetted`, `ownerWorkspaceId`, `sourcePackage`, `name`), so
every manifest-bearing registration write serializes on the same lock
and applies the same rules. The shared gallery fileId preservation moved
to a `buildRegistrationManifestUpdateFields` util. Tarball uploads can
no longer race installs on the registration row.

Behavior notes: a tarball re-upload whose manifest lacks
`application.displayName` now keeps the existing registration name
instead of resetting it to "Unknown App", and a re-upload without a
`package.json` version keeps the stored `latestAvailableVersion` instead
of nulling it — both strictly less destructive.

## 3. TARBALL upgrades (`b20aaba9`)

`upgradeApplication` only supported NPM; TARBALL apps had no update path
for installing workspaces. It now accepts TARBALL registrations and
re-installs the stored tarball, whose contents define the target version
— the install flow already gates same-version and downgrade installs.
The settings UI shows the latest-version row and the Upgrade button for
both NPM and TARBALL apps via a shared
`isUpgradableApplicationSourceType` util. LOCAL (dev-sync updates) and
OAUTH_ONLY (no code artifacts) stay rejected with a clearer message.

## Validation

- New tests: `validateVersionProgression` matrix in
`application-version-validation.service.spec.ts`,
`buildRegistrationManifestUpdateFields` gallery-preservation spec
- All 27 application suites (148 tests) pass; typecheck and lint green
on twenty-server and twenty-front
2026-07-17 09:06:20 +02:00
Weiko c6f0380070 Reuse onboarding container width for workspace selection (#22974)
Fix in https://github.com/twentyhq/twenty/pull/22965 was wrong, 440px
wide is the new intended width for both signup forms.
This PR reverts + does the correct fix.

See figma as source of truth
https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=1633-94880&p=f&m=dev

## Before
<img width="431" height="450" alt="Screenshot 2026-07-16 at 18 14 26"
src="https://github.com/user-attachments/assets/3f8788e1-3764-4326-867a-973a98e48007"
/>

## After
<img width="1030" height="898" alt="Screenshot 2026-07-17 at 08 24 38"
src="https://github.com/user-attachments/assets/42e03215-f32c-4f61-8f2d-1b8ff966d31c"
/>
<img width="1028" height="900" alt="Screenshot 2026-07-17 at 08 24 25"
src="https://github.com/user-attachments/assets/3a49142a-dc52-4097-96d2-05a4e641f574"
/>
2026-07-17 08:45:11 +02:00
Weiko 6a1de47a17 Fix signup visual regression in workspace selection layout (#22965)
## Summary
- Split the sign-in/up onboarding container styles so workspace
selection can keep its wider layout without affecting the other auth
states.
- Reuse the base onboarding container for the non-selection flow to
restore the intended visual structure.


https://github.com/twentyhq/twenty/commit/566c3b662954de932677a3fefe69735a45fe55ae
commit accidentally reused the 440px workspace-selection container for
the global credential form

## Before
<img width="643" height="496" alt="Screenshot 2026-07-16 at 18 14 35"
src="https://github.com/user-attachments/assets/abff0779-a236-424f-9503-9182dab5fa3f"
/>

## After
<img width="510" height="509" alt="Screenshot 2026-07-16 at 18 11 58"
src="https://github.com/user-attachments/assets/8c4693b2-827f-4bc8-a9ce-10171bbb7d0b"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22965?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-07-16 20:25:39 +02:00
Thomas Trompette 2a94736ece fix(metadata): resync metadata store via collection hashes on SSE reconnect (#22956)
## Context

Follow-up to #22562 (merged), which fixed the SSE-gap durability hole
from #22504 by calling `invalidateMetadataStore()` on SSE reconnect.

In review, @Weiko and a second reviewer flagged a performance concern:
firing a full invalidation on every reconnect can spam the backend, and
reconnect frequency is unbounded (`retryAttempts: Infinity`). The steer
was to lean on the per-collection hashes that `FindMinimalMetadata`
already returns and refetch only what actually changed.

## Problem

`invalidateMetadataStore()` sets `currentCollectionHash: undefined` for
every entity key. The staleness check in `useLoadMinimalMetadata` is
`entry.currentCollectionHash !== hash`, so nulling the hash makes
**every** collection compare as stale. Result: each reconnect forces a
full refetch of every metadata collection (objects, fields, views, ...),
even when nothing changed during the gap. That defeats the
collection-hash mechanism built to avoid exactly this.

## Change

Add `useResyncMetadataStore`, which only bumps
`metadataLoadedVersionState` without clearing collection hashes.
`MinimalMetadataLoadEffect` already re-runs on a version change, so this
triggers one `FindMinimalMetadata` query; the existing hash comparison
then marks only genuinely-changed collections stale.

`SSEClientEffect` now calls `resyncMetadataStore()` instead of
`invalidateMetadataStore()` on reconnect.

Net: same durability guarantee (changes missed during a disconnect are
caught on reconnect), but cost per reconnect drops from "refetch
everything" to "one lightweight hash query + refetch only what changed."

## Testing

1. Open a record page in a workspace.
2. Create a field / page-layout tab via the metadata API while the SSE
stream is dropped (background the tab, kill the network briefly, or
restart the server).
3. On reconnect the new metadata appears without a manual reload.
4. Reconnect with no metadata change triggers only a
`FindMinimalMetadata` query and no collection refetch.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22956?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-07-16 15:14:26 +00:00
Weiko 8e3ec5b43d Propagate record card background to inline hover content (#22957)
## Summary
- Introduce a shared `--record-card-background-color` CSS variable on
record cards
- Reuse that variable for hovered inline cell content so the hover
portal matches the card background state
- Preserve selected, focused, and active background transitions without
duplicating background logic

### Before

<img width="196" height="337" alt="Screenshot 2026-07-16 at 16 03 40"
src="https://github.com/user-attachments/assets/b74bfb24-0144-4a8c-b8a4-b56768e84d66"
/>
<img width="219" height="357" alt="Screenshot 2026-07-16 at 16 03 25"
src="https://github.com/user-attachments/assets/19d052cb-8c8c-49c9-b3af-0178c53c0c0a"
/>


### After

<img width="189" height="372" alt="Screenshot 2026-07-16 at 16 03 52"
src="https://github.com/user-attachments/assets/289e4186-d418-44dc-93d4-70fa47e50cf2"
/>
<img width="180" height="333" alt="Screenshot 2026-07-16 at 16 03 00"
src="https://github.com/user-attachments/assets/b874e0c4-5840-4b7e-918c-d441c50fa487"
/>



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22957?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-07-16 14:17:54 +00:00
Charles Bochet 5588ddf829 Fix delete/destroy/restore record commands on pages without a record index (#22952)
## Bug

On a standalone page (`/page/:pageLayoutId` — a custom app page or
standalone page layout), opening a record in the side panel and running
**Delete** from the Options menu fails with an error toast:

> Record index ID and object metadata are required to delete records

The record is not deleted. The same guard breaks **Destroy** and
**Restore**.

## Root cause

`buildHeadlessCommandContextApi` only derives `recordIndexId` when the
context store holds a `currentViewId`. On standalone pages there is no
view, and `useOpenRecordInSidePanel` copies that null view id into the
side panel context, so the delete/destroy/restore commands throw at
mount — before executing anything. The throw is caught by
`CommandMenuItemErrorBoundary` and surfaces as the toast (also reported
to Sentry).

The commands only use `recordIndexId` to reset table row selection and
remove records from the record board — cleanup that is meaningless when
no record index is on screen. The mutation itself only needs
`objectMetadataItem` and the graphql filter, which are both available.

## Fix

- Keep throwing when `objectMetadataItem` is missing (genuinely
required).
- Make `recordIndexId` optional: pass the existing
`PLACEHOLDER_RECORD_INDEX_ID` to the selection hooks (they must be
called unconditionally) and skip the selection cleanup at execute time
when there is no record index — same pattern
`useResetRecordIndexSelection` already uses. The constant is extracted
to a shared file.

## Verified

- **Bug path**: on a standalone page, opened a record in the side panel
via search, ran Delete Task from the Options menu → record soft-deleted
(checked `deletedAt` in DB), side panel closed, no error toast, no
console error.
- **Regression**: on the tasks index table, selected a row and ran
Delete Task from the command menu → record deleted, row removed, table
selection reset, no errors.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22952?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-07-16 16:13:15 +02:00
Weiko a7de3ce3a5 Allow non-compact all-day calendar cards to show full content (#22953)
## Summary
- Render all-day calendar items as full `RecordCalendarCard` content in
non-compact views, while keeping compact cards clickable as a whole.
- Rework the all-day time grid layout so the label and day cells align
cleanly in a dedicated grid row.
- Add coverage for the new card behavior and for filtering out
`DATE_TIME` records from the all-day lane.

### Week (compact)
<img width="1308" height="812" alt="Screenshot 2026-07-16 at 15 30 02"
src="https://github.com/user-attachments/assets/24f74f22-86c1-4326-8c65-92ee2c3e8c92"
/>

### Week (non compact)
**NEW**
<img width="1311" height="789" alt="Screenshot 2026-07-16 at 15 29 52"
src="https://github.com/user-attachments/assets/8445c8c5-c952-47e9-ba24-c21d63352e79"
/>

### Month
<img width="1310" height="822" alt="Screenshot 2026-07-16 at 15 29 41"
src="https://github.com/user-attachments/assets/aa33cf48-c602-49e6-bfb1-b9ab1c798bcb"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22953?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-07-16 15:59:12 +02:00
Akash! 18b746d525 fix(metadata): invalidate metadata store on SSE reconnect (#22504) (#22562)
## Fixes

Fixes #22504

## Description

This PR fixes a bug where metadata updates (like creating a new field or
a page layout) were missed if an open tab was temporarily disconnected
from the server (e.g., tab backgrounded or a network blip).

Because the frontend's metadata store relies on live SSE deltas for
freshness, any gap in the connection meant the new metadata would never
reach the client unless a full reload occurred. This often resulted in
"No Data" states for newly created layouts or widgets.

**Changes:**
- Updated `SSEClientEffect.tsx` to call `invalidateMetadataStore()` upon
a successful SSE reconnection.
- Re-syncing the metadata store on reconnect ensures that any events
missed during the disconnected gap are retrieved durably without
requiring a manual page refresh.

## Testing

1. Open a record page tab in a workspace.
2. From an app front component or DevTools, trigger a field creation via
the metadata API (`createOneField` / `page-layout` mutations).
3. Briefly disconnect the network or restart the server so the SSE
stream drops during the mutation.
4. Re-establish the connection.
5. The tab should automatically refetch the metadata and reflect the new
field/layout without needing a manual reload, instead of getting stuck
in a "No Data" state.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22562?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-07-16 14:23:20 +02:00
Charles Bochet 7ec7774087 Fix infinite redirect loop when logging out of a suspended workspace (#22949)
## Problem

Clicking **Log out** on a workspace suspended for a past-due
subscription locks the tab into an infinite redirect loop that hammers
the server with unauthenticated GraphQL requests until the tab is
closed.

Reproduced on cloud: workspace with `Pro plan • Past due` (activation
status `SUSPENDED`), user forced onto `/settings/billing`, click Log out
→ tab freezes, URL flip-flops between `/welcome` and
`/settings/billing`, requests stream out continuously.

## Root cause

`clearSession` nulls the `tokenPairState` atom and removes the persisted
session localStorage keys, but leaves the **in-memory**
`currentWorkspaceState`/`currentUserState` atoms populated, relying on
the subsequent `window.location.assign('/welcome')` reload to reset
them.

`PageChangeEffect` keeps running until that reload commits, and the
intermediate state (no token + suspended workspace) makes
`usePageChangeEffectNavigateLocation` ping-pong:

- on `/settings/billing`: no token → navigate to `/welcome`
- on `/welcome`: the no-token guard is skipped (`SignInUp` is in
`ONGOING_USER_CREATION_PATHS`), then `isWorkspaceSuspended` reads the
**stale** workspace atom → navigate back to `/settings/billing`

The synchronous navigation loop pegs the main thread, so the pending
full-page navigation never commits and the loop never resets. Every
bounce remounts pages whose queries refire without a token (each one
erroring `UNAUTHENTICATED`), plus Sentry envelopes — the server spam.

Verified during repro: mid-loop the tab had `tokenPairState="null"` and
no `currentWorkspaceState` in localStorage (the loop runs fully
unauthenticated off the in-memory atom), and an injected
`localStorage.setItem` wrapper survived the whole loop, proving the page
never reloaded.

## Fix

Clear the same in-memory auth atoms in `clearSession` that
`onUnauthenticatedError` (useApolloFactory) already clears:
`currentUserState`, `currentWorkspaceState`,
`currentWorkspaceMemberState`, `currentUserWorkspaceState`. With the
workspace atom gone, the suspended guard can't fire after logout, the
ping-pong never starts, and the redirect to `/welcome` commits normally.

## Test

Extended the `useAuth` sign-out test: seeds a suspended
`currentWorkspaceState` and a `currentUserState` before `signOut()` and
asserts both are null afterwards.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22949?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-07-16 14:05:56 +02:00
github-actions[bot] a69852c1b0 i18n - translations (#22927)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-15 18:58:32 +02:00
Weiko 8cf462d5f7 Add day view support to record calendar (#22922)
## Summary
- Add a calendar day view and wire it into the record calendar layout
selection
- Update the top bar, time grid, and week/day drag and drop handling to
support the new view
- Extend supported layout logic and public feature flags for calendar
day view access
- Add coverage for calendar view content, calendar container behavior,
top bar behavior, day view rendering, supported layout resolution, and
week event drop handling

<img width="1276" height="852" alt="Screenshot 2026-07-15 at 17 48 33"
src="https://github.com/user-attachments/assets/b1d9d255-2d64-4adb-82b9-3e500cb0d561"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22922?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-07-15 16:50:23 +00:00
Thomas Trompette 2dcf53619f fix(front): keep kanban header columns full-width when board overflows viewport (#22926)
## Problem

Before
<img width="1340" height="542" alt="Capture d’écran 2026-07-15 à 18 26
22"
src="https://github.com/user-attachments/assets/472dfe13-336a-43c7-8986-6cdcb04e5217"
/>

After
<img width="1340" height="542" alt="Capture d’écran 2026-07-15 à 18 26
12"
src="https://github.com/user-attachments/assets/e393a3fa-2245-42cc-b965-f16f3feaeff6"
/>

The record board (Kanban) header breaks when the screen is narrower than
the total column width. The stage header columns squish together to fit
the viewport while the cards below keep their fixed 220px width and
scroll horizontally, so the header labels no longer line up with their
columns.

Regression from #22323.

## Cause

#22323 wrapped each column header in `DragDropColumnSortableCell`. In
`fill` mode its `StyledSortableRoot` uses `min-width: 0` with the
default `flex-shrink: 1`, so the header cells collapse below their
column width when the board is wider than the viewport, instead of
overflowing into horizontal scroll like the body.

## Fix

Pin `flex-shrink: 0` in `fill` mode so header cells keep their column
width and overflow in step with the body. `fill` is board-only; the
record table header uses non-fill mode and is unaffected.

## Testing

Opportunities → "By Stage" Kanban, narrow the window below the total
column width: header stays aligned with the cards and scrolls
horizontally with them.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22926?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-07-15 16:43:37 +00:00
Charles Bochet 8e03921372 Add CREATED workspace activation status (read path + enum migration) (#22904)
## Context

Since v2 onboarding (#22303), workspaces are activated **before** the
billing plan step (now the last onboarding step). Users abandoning at
the plan step leave ACTIVE workspaces with a Stripe customer but no
subscription (~60–110/day on cloud, 935+ so far), and no cleanup
mechanism ever touches them: billing webhooks never fire (no
subscription), the suspended-workspaces cron only handles SUSPENDED, the
onboarding cron only handles PENDING_CREATION/ONGOING_CREATION.

Target lifecycle (across two PRs): `PENDING_CREATION → ONGOING_CREATION
→ CREATED → ACTIVE → SUSPENDED → deleted`.

**`CREATED`** = the workspace schema is provisioned but onboarding is
not complete — no billing subscription yet. It is **not** considered
active:

| Concern | CREATED behavior |
|---|---|
| Sign-in / invited teammates joining | allowed (invite-team step
precedes the plan step) |
| Member + metadata loading (app shell) | allowed (user must finish
onboarding) |
| Permissions | real permission checks (no PENDING-style bypass) |
| Version upgrades / workspace migrations | **included** (schema must
not drift) |
| Messaging/calendar/workflow/etc. crons | **excluded** — no background
processing until a plan is chosen |
| PLAN_REQUIRED onboarding lock | unchanged (still derived from
subscription existence) |

## What this PR does (read path only)

The enum addition ships as a **slow** instance command, which can run
after deploy — so nothing in this PR ever **writes** `CREATED`. The
write path (setting it at activation, the cleanup sweep, the backfill of
the existing zombie cohort) is a follow-up PR that ships once this
migration has run everywhere.

- **twenty-shared**: `CREATED` enum value;
`PROVISIONED_WORKSPACE_ACTIVATION_STATUSES` + `isWorkspaceProvisioned`
("schema exists": CREATED | ACTIVE | SUSPENDED), replacing
`isWorkspaceActiveOrSuspended` — all call sites (server member loading,
access-token workspace-member lookup, front metadata-store gates) meant
"has schema/members".
- **Slow instance command** (2.22.0): swaps
`core.workspace_activationStatus_enum` using the
rename→recreate→alter-column idiom. The CHECK constraints on
`core.workspace` embed casts to the enum type and would break the swap —
the command captures them from `pg_constraint`, drops them, swaps the
type, and restores them.
- **Pre-migration-safe queries**: Postgres rejects `IN ('CREATED', ...)`
when the enum value does not exist yet — even for reads, and the
instance-command runner itself queries provisioned workspaces before
migrating (a fresh database could never initialize). All
provisioned-status filters go through a new `activationStatusIn` util
comparing on `"activationStatus"::text`, valid before and after the
migration.
- **Upgrade path**: workspace iterator, command runner, upgrade-status
and workspace-version services iterate CREATED workspaces. Since they
now cover more than ACTIVE/SUSPENDED, the stale names were renamed to
`ProvisionedWorkspaceCommandRunner`, `hasProvisionedWorkspaces`,
`getProvisionedWorkspaceIds`, `loadProvisionedWorkspaces` (the
mechanical import rename in old version-command dirs is why this PR
carries the `ci:allow-previous-version-upgrade-mutation` label).
- **Sign-in**: `throwIfWorkspaceIsNotReadyForSignInUp` accepts CREATED
so invited members can join during onboarding (join authorization itself
is unchanged — enforced upstream in `checkAccessForSignIn`);
`activateWorkspace` idempotent-retry accepts CREATED as a terminal
state.
- **Transitions out of CREATED** (only write ACTIVE — safe to ship now,
dead until the write path lands): the Stripe webhook reactivation branch
also promotes CREATED, and `syncSubscriptionToDatabase` promotes
synchronously; both gated on
`WORKSPACE_ACTIVATING_SUBSCRIPTION_STATUSES` (Active/Trialing —
extracted from `shouldReactivateWorkspace`, behavior-preserving) so an
`incomplete` subscription created by the payment-intent flow before
payment never promotes the workspace.
- Deliberately untouched: all background crons, permission guards, JWT
strategy, PLAN_REQUIRED logic, admin panel (renders the raw status
string).

## Follow-up PR (after this migration has run)
1. `activateWorkspace` sets `hasWorkspaceAnySubscription ? ACTIVE :
CREATED` (billing disabled → always ACTIVE, self-hosted unchanged).
2. Cleanup: suspend CREATED workspaces older than N days (config var),
handing them to the existing suspended pipeline (warn → soft-delete →
destroy).
3. Backfill: cloud-only slow command moving ACTIVE workspaces with no
billingSubscription row (created since Jul 1) to CREATED.

## Verification
- Migration exercised against a real database via the command class: up
→ down → up; `enum_range` and `pg_get_constraintdef` checked after each
step (constraints restored against the new type, `DEFAULT 'INACTIVE'`
preserved).
- Pre-migration safety exercised for real: with the migration rolled
back (enum without CREATED), `run-instance-commands` — the exact
fresh-database CI path that failed before the `::text` fix — completes
cleanly.
- End-to-end with a workspace manually set to CREATED and the branch
server+front running: sign-in issues tokens, `currentUser` loads
workspaceMember(s), the full app loads with no console errors; GraphQL
returns `activationStatus: CREATED`.
- Workspace creation ran end-to-end locally in **both billing modes** on
this branch:
- billing disabled: signup → workspace creation → ACTIVE immediately →
onboarding completes with no plan step → app loads (unchanged behavior);
- billing enabled (Stripe test mode): signup creates the Stripe customer
eagerly → activation ends ACTIVE → subscription-less workspace is pinned
to the plan-required page → no-card trial checkout creates a `trialing`
subscription via `createDirectSubscription`/`syncSubscriptionToDatabase`
→ app loads.
- `twenty-shared` unit tests, server specs on touched services,
`lint:diff-with-main` and `typecheck` for shared/server/front all green;
full CI green.
2026-07-15 17:03:17 +02:00
github-actions[bot] 36a14478ae i18n - translations (#22916)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-15 16:38:28 +02:00
Weiko 25bd2897a3 Add weekly layout to record calendar (#22819)
## Summary

- Add a week layout to record calendar views and persist the selected
layout.
- Render `DATE` calendars as an all-day week and `DATE_TIME` calendars
as an hourly week.
- Add an optional end date field across calendar configuration,
metadata, persistence, and complete-view upserts.
- Use configured end values for ranged and multi-day events, with a
one-hour fallback when a `DATE_TIME` end is absent or invalid.
- Keep calendar cards consistent with the existing compact view,
including checkbox selection and whole-card record opening.
- Gate the weekly layout and end-date behavior behind the public Labs
`IS_CALENDAR_WEEK_VIEW_ENABLED` workspace feature flag.

## Week interactions

- Show overlapping timed events side by side and cap the visible records
at two per day.
- Display start and end times on timed cards, enforce a readable
30-minute minimum height, and keep today’s text contrast stronger.
- Drag timed events between days and times with 30-minute snapping while
preserving their duration, including zero-duration events.
- Show a create button when hovering a 30-minute slot; keyboard users
can focus a day, move the slot with the arrow keys, and reach the same
contextual action.
- Initialize new records with the selected slot time and a compatible
writable end value one hour later.
- Show the workspace time zone and current-time indicator in timed
weeks; date-only weeks keep the all-day section without an hourly grid.

## Configuration and data loading

- Only allow end fields that match the start field type, and prevent
selecting the same field for both boundaries.
- Load records whose ranges overlap the visible period so month and week
layouts display the same relevant records.
- Resolve and persist calendar end fields when updating existing views
through `upsert_complete_view`.
- Fall back to Month and ignore the configured end field while the flag
is disabled, without overwriting either persisted setting, so
re-enabling restores the previous configuration.
- Expose the flag in Labs and keep it default-off for workspaces without
a stored value; enable it in the development seeder.

<img width="1285" height="808" alt="Screenshot 2026-07-15 at 15 50 17"
src="https://github.com/user-attachments/assets/b7e3f7f1-ca77-492f-8cce-cca186ebca0b"
/>
2026-07-15 16:30:18 +02:00
martmull 0dbae2eda3 Address #22827 review comments and converge application file endpoints (#22868)
Follow-up to #22827, addressing the review comments left around merge
time and applying the endpoint convergence discussed afterwards.

## Review comments from #22827

- **Swallowed error in dev sync asset read**
([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571513265)):
the swallow is intentional (a missing public asset must not fail the
whole dev sync) but it now logs a warning with the asset path and error,
and the registration keeps its previously stored file for that path
instead of losing it.
- **`isAbsoluteUrl` location**
([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571524234)):
moved to `twenty-shared/utils/url`. The server, and now also
`twenty-sdk`'s `normalize-application-assets`, use the shared util.
- **Soft delete vs file cleanup**
([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571589558)):
per review, deleting a registration is now a hard delete. Stored assets
(bytes + rows) are deleted with it, dependent rows are removed by their
existing FK cascades, and installed applications keep working with their
registration link nulled. No soft-delete/cron mechanism.
- **Asset cap too generous**
([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571595745)):
lowered to 10MB per review and documented in the publishing and
public-assets docs pages.
- **One missing image retriggers a full asset sync**
([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571646243)):
`storeRegistrationAssets` now takes `skipAlreadyStoredPaths`; the
catalog sync passes it when the package version is unchanged, so only
assets missing a stored file are fetched instead of re-downloading
everything.
- **`existing.logo` already contains the new logo**
([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571667847)):
correct, `updateFromManifest` runs first, so the previous "keep fileId
when the path did not change" guard compared the new logo against
itself. The fileId preservation is now keyed on the stored server file
for the exact path (files are unique per `(applicationRegistrationId,
path)`): a changed logo path no longer inherits the old file's id, and a
transient download failure on an unchanged path still keeps the working
file. This also removed the fileId-preservation bookkeeping from
`storeRegistrationAssets`.

## Endpoint convergence

- **Path-addressed public route for registration assets**: `GET
/file/server/application-registration/:fileId` is replaced by `GET
/files/application-registrations/:registrationId/*path`, mirroring the
manifest's public-folder paths and leaving room for a future `:version`
segment. Assets stay addressable by stable ids server-side; the fileId
now only marks a path as stored. No URL is ever persisted (all are built
at query time), and the old route never shipped in a release, so there
is nothing to migrate.
- **`Application.logoUrl` resolved server-side**: new `ResolveField` on
the `Application` type builds the `/public-assets/...` display URL (or
passes absolute URLs through). `useApplicationChipData` now reads it
from `currentWorkspace.installedApplications`, and the frontend
`buildApplicationLogoUrl` util is deleted, so clients no longer
construct file URLs themselves.

## Validation

- Unit: `file.controller.spec` (route renamed, traversal case added),
`server-file-storage.service.spec` (`findServerFile`,
`deleteByApplicationRegistrationId`),
`application-registration-asset-url.service.spec` (new URL shape,
url-encoding), new `isAbsoluteUrl` test; all application/file suites
pass.
- Live against a local server: new route serves tarball and rehosted npm
assets with `public, max-age=3600` (nested paths included), 404s on
missing files, unknown registrations, traversal attempts, and the
removed old route; `findManyApplicationRegistrations` returns
path-addressed URLs for stored assets, CDN fallback for npm, absolute
passthrough; `installedApplications.logoUrl` resolves the public-assets
URL and stays null for logo-less apps. Registration hard delete verified
against the DB: file rows cascade, application rows keep a nulled
registration link.
- Typecheck + lint on twenty-server, twenty-front, twenty-shared,
twenty-sdk; metadata codegen and client-sdk regenerated.
2026-07-15 16:12:28 +02:00
github-actions[bot] 907c5cc39f i18n - translations (#22913)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-15 15:54:27 +02:00
Abdul Rahman f4ff234db8 feat: make record avatar/icon resolution data-driven via a configurable image identifier field (#22644)
## Summary

Today the avatar/icon shown for a record is hardcoded per object —
Company pulls a favicon from its domain link, Person uses `avatarUrl`,
etc. This PR replaces that hardcoding with a generic, data-driven
abstraction based on a configurable **image identifier field** on each
object's metadata (mirroring the existing **label identifier** concept).

An object's image identifier can point to:
- a **`FILES`** field → the uploaded image is used directly (rounded
avatar), or
- a **`LINKS`** field → a favicon is derived from the primary URL via
the Twenty icons service (squared avatar), gated by
`ALLOW_REQUESTS_TO_TWENTY_ICONS`.

This lets any object type (Opportunity, a custom "Listing", etc.) define
its own avatar/icon without code changes, and makes the field
configurable/overridable for standard objects.


##  Open question: also allow `TEXT` → direct image URL?
Right now the image identifier is restricted to `FILES` (uploaded file)
and `LINKS` (favicon). We deliberately left out `TEXT` → **direct image
URL** (e.g. an imported/synced photo URL stored in a text field).
There's precedent for it — Person's avatar was originally a `TEXT`
`avatarUrl`, and WorkspaceMember still is — and it's unambiguous (a
`TEXT` field has no favicon-vs-image ambiguity, and selecting it as the
image identifier is itself the declaration of intent). It's a small,
clean extension:
- add `TEXT` to the allowed image-identifier types,
- add an explicit `TEXT → raw URL` case
- `getAvatarType`: `TEXT → rounded`.
Caveats: it relies on admin assertion that the text values are image
URLs (no data-level guarantee), and external image URLs load third-party
content in the browser (IP-leak/hotlinking, same as favicons — a
proxy/cache would be the more robust long-term answer).

###  Resolution
Decision: **we will not support `TEXT` as an image identifier.** Image
identifiers stay restricted to `FILES` and `LINKS`, and any other type
fails closed (returns no avatar) on both the frontend and backend.
Instead, the legacy items that still rely on a `TEXT` avatar — Person's
deprecated `avatarUrl` and WorkspaceMember's `avatarUrl` — will be
migrated to `FILE` fields in a follow-up PR. Until then, WorkspaceMember
remains an exception (its `avatarUrl` still resolves through the
existing CorePicture path), and legacy Person `avatarUrl` values that
haven't been migrated will show initials placeholders.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22644?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-07-15 19:15:47 +05:30
Thomas Trompette 4e83a64f81 fix(front): read fresh metadata in SSE update path to avoid false unknown-field warnings (#22897)
## Problem

Sentry `warning`: *"SSE update event for person carried fields unknown
to this tab's metadata: lastInboundAt, pdlCertifications, pdlBirthYear,
…"* ([TWENTY-FRONT
issue](https://twenty-v7.sentry.io/issues/7610855477)).

The listed fields are all custom fields created by the **People Data
Labs app** (`packages/twenty-apps/public/people-data-labs`). The flow
that triggers this:

1. The app installs a batch of `person` fields (emits metadata SSE
events).
2. Its enrichment logic-function updates a person record with all of
those fields (emits a record-update SSE event).

Field creation already emits metadata SSE events, and the front applies
them **synchronously** to the Jotai metadata store
(`MetadataStoreSSEEffect` → `applyChanges` → `store.set`). So the store
converges.

The bug is that the SSE **record-update** handler doesn't read the
converged store. `useTriggerOptimisticEffectFromSseUpdateEvents` reads
`objectMetadataItems` from a React/Jotai closure captured at render
time. The long-lived SSE subscription in `useTriggerEventStreamCreation`
holds a metadata snapshot that lags the store, so even after the
field-create events have been applied, the record path still sees the
old field set. It then:

- flags the new fields as "unknown" and logs to Sentry, and
- **drops those field values** from the optimistic update
(`getUnknownRecordInputFields` filters them out), so already-loaded
views miss the enriched data until a refetch.

Metadata events are dispatched before record events within each SSE
message (`useTriggerEventStreamCreation` lines 126-128), and the store
update is synchronous, so a fresh store read at processing time sees
fields that converged in the same or any earlier message.

## Fix

Read `objectMetadataItems` fresh from the Jotai store at
event-processing time instead of from the render closure, and re-resolve
the object metadata item from that fresh list. This eliminates the
false-positive warnings and stops dropping legitimately-known field
values.

Follows the design from #22474: the metadata-event pipeline owns schema
convergence; the record pipeline just reads the converged store (now
actually reading the current store rather than a stale snapshot). A
genuine race where the record update truly precedes the field-create
event is still tolerated and still warns.

## Testing

- `nx typecheck twenty-front` passes
- `nx lint:diff-with-main twenty-front` passes

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22897?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-07-15 11:53:08 +00:00
martmull 055e8b5335 Add tab param to open a record side panel page on a specific tab (#22905)
## Context

`CommandOpenSidePanelPage` (and `openSidePanelPage` in the front
component SDK) could open a record in the side panel, but always landed
on the default tab. This adds an optional `tab` param to the
`ViewRecord` page params so an app command can open a record directly on
a specific tab.

## What changed

- **twenty-sdk**: `OpenSidePanelPageParams` `ViewRecord` variant accepts
an optional `tab` (a page layout tab id). Since
`CommandOpenSidePanelPage` props are `OpenSidePanelPageParams`, the
component picks it up automatically.
- **twenty-front**:
- New `setRecordPageActiveTabId` util resolves the record page layout
for the object (custom layout from the store, or the default layout id)
and presets `activeTabIdComponentState` on the tab list instance
(`${pageLayoutId}-tab-list-${recordId}`), which is shared by the side
panel and the full record page.
- `useOpenRecordInSidePanel` accepts `tab` and presets the active tab
before navigating; it also applies when the record is already open in
the side panel (tab switch only).
- `useFrontComponentExecutionContext` forwards `tab` to the side panel
open, and presets the tab when falling back to full-page navigation
(mobile, or objects that can't open in the side panel).
- **Docs**: mention the optional `tab` id in the
`CommandOpenSidePanelPage` description.

Unknown tab ids are harmless: `PageLayoutTabListEffect` falls back to
the layout's default tab when the preset id doesn't exist in the layout.
Dashboards are skipped since their layout id comes from record data, not
object metadata.

## Tests

- `useOpenRecordInSidePanel`: new test asserting the active tab atom is
preset on the correct tab list instance id.
- `useFrontComponentExecutionContext`: new tests for tab passthrough to
the side panel and tab preset on full-page fallback.
- `npx nx typecheck twenty-front`, `typecheck twenty-sdk`,
`lint:diff-with-main twenty-front`, `lint twenty-sdk` all green.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22905?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-07-15 13:39:11 +02:00
Charles Bochet 75d9e0b93a fix(front): constrain 2FA sign-in screens and dedupe their shared shell (#22886)
## Problem

On the card-less onboarding sign-in (`/welcome`), the **2FA
verification** step renders with a full-viewport-wide submit button.

The 2FA **verify** and **provision** forms hard-code `width: 100%` on
their root `StyledForm`. That was harmless while `/welcome` rendered
inside the `AuthModal` `medium` card, which bounded the width. Since
#22398 removed v1 onboarding, `/welcome` renders card-less under
`BlankLayout`, so nothing bounds those forms and they stretch to the
full viewport.

Other steps are **not** affected, which is why only 2FA looks wrong:
- Sign-in form: root sets `width: ONBOARDING_CONTENT_BLOCK_WIDTH;
max-width: 100%` -> capped at 440.
- SSO selection / workspace-scope: base container (`min-width: 240`, no
width) -> shrink-to-fit.
- **2FA verify / provision: `width: 100%` -> full viewport.**

## Fix

Cap the two 2FA forms at `ONBOARDING_CONTENT_BLOCK_WIDTH` (with
`max-width: 100%`), so they sit in the same block as the sign-in page
instead of forcing full-width.

## Refactor (same PR)

The verify and provision components (both introduced together in #13141)
duplicated their layout shell. Extracted the shared instruction-text and
main-content blocks into `SignInUpTwoFactorAuthenticationStyles.ts`. The
form container stays local to each component since the element differs
(a `div` in provision, a `form` in verification).

## Verification

- Reproduced the flex box-model at 1280px: `width:100%` root -> 1196px
full-width button; `width: 440px` -> 440px centered button.
- lint + format + typecheck pass on the changed files.
2026-07-14 17:16:01 +02:00
Harshit Raizada fd7935f343 fix(front): allow dismissing [Credits limit reached] banner (#22843)
Dismiss is session-only, he banner reappears on reload, because the
underlying "out of credits" condition is still true
closes #22774 

fix: 
<img width="1222" height="147" alt="image"
src="https://github.com/user-attachments/assets/082e68c1-d121-4e31-b261-386de8231896"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22843?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-07-13 17:34:09 +02:00