Commit Graph

6223 Commits

Author SHA1 Message Date
martmull 2a495c3477 feat(app): allow claiming ownership of unclaimed app registrations (#22609)
## After

<img width="653" height="703" alt="image"
src="https://github.com/user-attachments/assets/ebe800da-b00b-4239-99a9-e157f7bfd7a0"
/>
<img width="634" height="711" alt="image"
src="https://github.com/user-attachments/assets/00a048bf-36b1-489f-a080-1ed2d069e625"
/>


## Context

App registrations track their owner via `ownerWorkspaceId`. Curated /
catalog / CLI apps are seeded **unclaimed** (`ownerWorkspaceId: null`).
Until now there was no way to take ownership of an unclaimed app from
the UI — the only ownership action was **Transfer ownership**, which
requires the caller to already be the owner, so it can't act on a
null-owner app.

This PR adds a way to **claim** an unclaimed app registration, and makes
the owner always visible on the detail page.

## Behaviour

Admin panel → app registration detail → General tab:

- The **Owner** row is now always shown — an **Unclaimed** tag when
there's no owner workspace (previously the row was hidden).
- Danger zone buttons are ownership-aware:
- **Unclaimed** app → **Delete app** + **Claim ownership** (claims it
for the current workspace).
  - **Owned** app → **Delete app** + **Transfer ownership** (unchanged).

Transfer is hidden for unclaimed apps because transferring requires the
caller to already own the registration.

## Changes

**Backend**
- New `claimOwnership` service method: looks the registration up
globally, rejects it if it already has an owner, otherwise assigns
`ownerWorkspaceId` to the caller's workspace.
- New `claimApplicationRegistrationOwnership` mutation, guarded by
`WorkspaceAuthGuard` + `SettingsPermissionGuard(APPLICATIONS)` (same
guards as transfer).
- New `ClaimApplicationRegistrationOwnershipInput` DTO
(`applicationRegistrationId`).

**Frontend**
- **Claim ownership** button (shown only when the registration has no
owner workspace); opens a confirmation modal and calls the new mutation.
- **Transfer ownership** button now renders only for owned
registrations.
- The **Owner** row in the general info card is always displayed, with
an `Unclaimed` tag when there is no owner.

**Generated**
- Regenerated the checked-in GraphQL artifacts (`twenty-front` metadata,
`twenty-client-sdk` schema/types) against the live server so codegen
output matches.

## Verification
- `nx typecheck twenty-front` and `nx typecheck twenty-server` pass.
- `oxlint` + `oxfmt` pass on all changed source files.
- Codegen is idempotent — re-running the three `graphql:generate`
configs + `generate-metadata-client` produces no diff.
- Verified end-to-end on the running app against the seeded unclaimed
`Twenty CLI` registration (Owner shows `Unclaimed`; Danger zone shows
Delete + Claim ownership).

https://claude.ai/code/session_01U7rbxhBSUQRWBbdP5TmAgZ
2026-07-07 11:07:21 +02:00
Raphaël Bosi c55bfab11e Fix side panel open/close animation glitches in the page header and panel content (#22598)
When the side panel opens or closes, the pinned header button (e.g. New
Company) was flat-clipped for the whole 300ms animation: the ⋮ toggle
mounts instantly and shifts the button's slot, framer's `layout` prop
compensates with a transform that the surrounding `overflow: hidden`
containers don't follow, and the ResizeObserver-driven rerenders re-seed
that transform every frame. Removing `layout` lets the button follow
plain reflow, which cannot clip.

The panel content also squeezed during the animation (labels
re-truncating at every intermediate width) because the inner panel was
`width: 100%` of the width-animating wrapper. Pinning it to
`var(--side-panel-width)` turns the animation into a rigid drawer slide;
drag-resize is unaffected since it writes the same CSS variable.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22598?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-07 10:40:26 +02:00
Raphaël Bosi d3b79320b1 Remove book a call step from onboarding (#22597)
The book a call screen was shown as a dedicated onboarding step after
sending team invites. It is no longer part of the flow: the
`BOOK_ONBOARDING` status, its pending user var, the
`skipBookOnboardingStep` mutation and the `BookCallDecision` screen are
removed, and onboarding completes right after the plan step.

The `/book-call` Cal.com page remains, reachable only from the "Book a
Call" link on the upgrade screen, with a back link to `/plan-required`.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22597?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-07 10:34:20 +02:00
martmull ee28ae363f feat(files): use direct-to-storage upload for email and AI-chat attachments (#22610)
## Context

Follow-up to the direct-to-storage upload work (#22449 / #22531 / #22533
/ #22576). That migrated files-field, attachments and workflow uploads
off the buffered path. This PR does the same for the **last two
user-facing upload surfaces**: email attachments and AI-chat files.

## What this does

- Adds `EmailAttachment` and `AgentChat` to the server's
`DIRECT_UPLOAD_FILE_FOLDERS` allowlist. Both folders already resolve
through the workspace-custom-application path in
`resolveUploadLocation`, so no other server change is needed.
- Routes the two frontend hooks through the existing
`useDirectFileUpload` handshake (`createFileUpload` → `PUT` →
`completeFileUpload`):
- `useUploadEmailAttachment` → `FileFolder.EmailAttachment` (keeps its
existing `MAX_ATTACHMENT_SIZE` client check — email has a real send-size
limit).
  - `useAiChatFileUpload` → `FileFolder.AgentChat`.

Each hook keeps its public signature and return shape, so call sites are
unchanged. No schema change and no codegen needed — the
`CreateFileUpload`/`CompleteFileUpload` documents and the `FileFolder`
enum values already exist in `generated-metadata` from #22576.

## Why these are safe to migrate

Both server services (`file-ai-chat`, `file-email-attachment`) just
`writeFile` (store) and return a signed URL — no synchronous processing
of the bytes at upload time — so the store-and-reference direct-upload
flow fits exactly, same as files-field/workflow.

## Out of scope

`CorePicture` (avatars, member/workspace pictures, logos) stays on the
buffered path on purpose: small images that go through server-side image
handling and are served inline, where the 10 MB body limit is already
appropriate.

## Tests

Extends the `FileUploadService` unit spec with an `it.each` asserting
`createFileUpload` supports the `EmailAttachment` and `AgentChat`
folders.

## Verification

`typecheck` and `lint:diff-with-main` green on both `twenty-front` and
`twenty-server`. (The server jest suite couldn't run in my local sandbox
due to an unrelated config-import quirk present on a clean `main`
checkout too — CI runs it normally.)

https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22610?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-07 10:26:04 +02:00
Marie 8a4bcd1445 (Billing for self hosts) Tie enterprise key to server (#22464)
# Enterprise key: bind to a server, free dev instances, self-serve
transfer, shorter license

## Summary

Enterprise keys were being reused across multiple instances (e.g. one
prod + one dev, or several environments), which broke seat accounting
and made licensing ambiguous. This PR ties each enterprise key to a
**single server**, while giving customers a legitimate, self-serve way
to run a **free development instance** and to **move their key** when
they replace a server.

## Product behavior

### 1. Enterprise key is bound to one server
- The first server to validate an enterprise key **claims** it
(claim-on-first-use). From then on, that key is bound to that one server
(until unbound - see 3.).
- Any other instance that presents the **same key from a different
server is hard-rejected**: it does not receive a license, so enterprise
features stay off there.
- Each instance has a stable server identifier. If one isn't set, the
instance generates and persists one automatically on first validation
(in keyValuePair table), so existing customers generally don't need to
do anything (unless they have disabled config variables in db then they
should add it to .env).

### 2. Free development instance
- Every enterprise subscription gets **one free, non-billable
development instance** in addition to its production instance.
- An instance registers as development by declaring its instance type as
`development` (done by default when validating the enterprise key, then
can be toggled from UI or by updating value in keyValuePair table).
- The free dev slot is only granted while there is an **active
production instance** on the same subscription (so it's a perk for
paying customers, not a way to run for free).
- Only **one** dev instance can be active at a time per subscription,
and it is **not counted as a billable seat**.

### 3. Self-serve unbind / rebind (transfer)
- Admins can **release** the binding from the enterprise settings, which
frees the key so it can be **claimed by a new server**.
- This is the intended path when **sunsetting an instance and standing
up a new one** (migration, re-hosting, disaster recovery): release on
the old/dead box, then the new box claims it on its next validation.
- To prevent abuse, releases are **rate-limited (10 per rolling 30
days)**; hitting the limit shows a clear message.

### 4. Automatic release of dead servers
- If a bound server stops checking in for **14 days**, its binding is
considered stale and is **auto-released**, so a replacement can claim
the key without any manual step. This covers the case where the old
server is already gone and can't release itself.

### 5. Shorter license validity (30 → 7 days)
- The license (validity token) now expires after **7 days** instead of
30. The daily background refresh keeps healthy instances licensed
transparently.
- This limits the value of copying a license from one instance to
another, since a copied license now stops working within a week.

### 6. License issuance is rate-limited
- Issuing a new license is capped at **twice per 24h, independently for
production and for development**. This tolerates the normal daily
refresh (including small drift between runs) while blocking bursts of
license minting for cloned instances.
- Hitting this limit never revokes an existing, still-valid license —
the current one keeps working until it expires; the manual "refresh"
button just reports that the daily limit was reached.

## What changes for existing self-hosted customers

**If you run a single production instance with one enterprise key:**
nothing to do. On the next validation your instance reports its server
identifier, claims the binding, and keeps working.

**If you reuse one key across several instances (e.g. prod + dev, or
multiple environments):** only the **first** instance to validate keeps
its license. The others will **lose enterprise features**. To migrate:
- Keep your production instance as-is (it claims the binding).
- For a secondary/testing box, mark it as a **development instance**
(set the instance type to `development`) to use the free dev slot — no
extra cost.
- If you genuinely need multiple production instances, you'll need
**separate subscriptions/keys** for each.

**If you're replacing a server (decommissioning + rebuilding):**
- **Release** the binding from enterprise settings on the old instance,
then start the new one — it will claim the key automatically.
- If the old server is already gone, just wait for the **14-day
auto-release**, or contact support.

**Legacy instances that can't persist a server identifier
automatically:** set the server identifier explicitly in your
environment configuration (the instance logs a message telling you to do
so).

**Offline instances:** because licenses now last 7 days, an instance
that can't reach our licensing endpoint for more than a week will lose
enterprise features until it can check in again.

> A migration email will be sent to affected customers separately.

## Technical implementation (brief)

- Binding state lives in the **subscription's billing metadata** (bound
server id + last-seen timestamps for prod and dev, release timestamps,
and license-issuance timestamps). No new database is introduced on the
licensing side; the billing provider's subscription metadata is the
source of truth.
<img width="976" height="413" alt="metadata_3"
src="https://github.com/user-attachments/assets/ccc64822-e177-4223-a65a-4a4602aedf0e"
/>

- On each validation, a pure **binding resolver** takes the reported
server id + instance type + current metadata and returns `allowed` (with
the metadata to persist and whether the seat is billable) or `rejected`.
It handles claim-on-first-use, staleness/auto-release, the
dev-requires-active-prod rule, and the single-dev-slot rule.
- **Rate limits** (release + license issuance) use a shared
sliding-window helper stored as pruned timestamp lists in the same
metadata, so the metadata self-cleans and never grows unbounded. License
issuance uses **separate windows per instance type**.
- The self-hosted instance **generates and persists a server
identifier** if none is configured, and sends it (plus instance type) as
instance metadata on validation.
- A rejected binding returns a specific error code; the instance
**revokes its stored license** on that code. A license-issuance
rate-limit instead **throws a typed exception that surfaces to the
manual refresh** while leaving the existing license untouched; the daily
refresh job swallows it.
- License lifetime is a configurable duration (defaulted from 30 to **7
days**), clamped to the subscription's cancellation date when sooner.
2026-07-06 18:07:03 +02:00
Weiko 8580cd6f27 feat(ai): open Ask AI side panel with a preprompt in two modes (#22582)
Add the ability to open the Ask AI side panel pre-filled with a prompt
from any frontend component, with a mode to control whether the message
is sent automatically or left for the user to review.

- agentChatPrepromptState: holds the pending preprompt and its mode
(PREFILL = fill only, SEND = fill and auto-submit)
- useOpenAskAiPageWithPreprompt: seeds the new-thread draft, opens a
fresh Ask AI thread and stores the preprompt intent
- AgentChatPrepromptEffect: applies the intent once the chat editor and
send listener are mounted, either restoring the editor content or
dispatching the send event and clearing the editor

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22582?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-06 15:38:28 +00:00
Raphaël Bosi 62c7e8f6b4 Polish onboarding v2 verify animation and step screens (#22585)
https://github.com/user-attachments/assets/1d9b8dc2-ef01-4202-a97e-b41cab048f87





A few polish tweaks to onboarding v2:

- **Verify/workspace-creation animation:** emphasize the key phrase of
each message in medium weight, the rest regular (e.g. "Creating your
**workspace**…").
- **Wider content column:** 340px → 440px. Collapses to full width on
mobile via the existing `max-width: 100%` on every consumer.
- **Sticky disabled buttons:** step submit buttons now stay disabled
from submit through navigation instead of briefly re-enabling once the
mutation resolves.
- **Fewer pulse loaders:** stop the pulsing logo from flashing when
navigating between onboarding steps (removed the step-page Suspense
fallback loader). The verify animation, cold-boot gates, and sign-in
fallbacks are unchanged.

Note: the reworded activation messages get new Lingui catalog IDs, so
non-English locales fall back to English until catalogs are
re-extracted.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22585?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-06 14:44:44 +00:00
Paul Rastoin 6c40c7b91a Deterministic system field universal identifier (#22565)
# Introduction

Close twentyhq/core-team-issues#2641

Auto-provisioned field metadata used to get its `universalIdentifier`
from three unrelated sources: random `v4()` on the server when creating
custom objects, hardcoded values in `STANDARD_OBJECTS`, and an ad-hoc
`v5` derivation in the SDK manifest build. This PR unifies all of them
behind the shared `getFieldUniversalIdentifier` derivation:

```
universalIdentifier = f(applicationUniversalIdentifier, objectUniversalIdentifier, fieldName)
```

## Ownership model

The rollout is built on an explicit split of who owns a field's
universal identifier:

- **The 8 system fields** (`id`, `createdAt`, `updatedAt`, `deletedAt`,
`createdBy`, `updatedBy`, `position`, `searchVector`) are
**server-owned**. Their universal identifiers are always the
deterministic derivation, on **every** application (standard,
workspace-custom, installed). Clients cannot provide custom values: a
temporary check in `validateObjectMetadataSystemFieldsIntegrity` rejects
any non-derived system field identifier at migration build time. This
check stands in until system fields are generated exclusively server
side by the metadata side-effect engine and stripped from client inputs
— at which point it becomes structurally impossible to send one.
- **`name` is a default field, not a system field**: it is
auto-provisioned when absent (server side for custom objects, SDK side
for application objects) but authors can define their own. It is only
derived where it is guaranteed to be auto-provisioned. In particular,
standard objects keep their **historical hardcoded** `name` identifiers:
the standard app authors its `name` fields like any installed app would,
and moving those identifiers would break every installed application
referencing them (e.g. views on `opportunity.name`).
- **User-created and author-provided fields** keep random / explicit
identifiers, untouched.

## Server

- `validateObjectMetadataSystemFieldsIntegrity` now validates, on top of
the existing type/`isSystem` checks, that each system field's
`universalIdentifier` equals the deterministic derivation. Runs for
every object creation going through the migration orchestrator: app
sync, custom object creation, standard provisioning
- `build-default-flat-field-metadatas-for-custom-object.util.ts` derives
the system field identifiers (and the auto-provisioned `name`) with
`getFieldUniversalIdentifier` instead of `v4()`
-
`build-default-relation-flat-field-metadatas-for-custom-object.util.ts`
derives both the forward and the reverse default relation field
identifiers deterministically
- `generateMorphOrRelationFlatFieldMetadataPair` accepts optional
`sourceFieldUniversalIdentifier` / `targetFieldUniversalIdentifier` so
callers can inject deterministic values; user-created relations still
default to `v4()`

## twenty-shared

- `STANDARD_OBJECTS` system field identifiers (the 8) are now computed
at module load via `buildStandardObjectSystemFields`; `name` and every
other identifier keep their hardcoded values
- New snapshot test pinning **every** universal identifier of
`STANDARD_OBJECTS`: any identifier change now requires an explicit
snapshot update and should ship with a coordinated backfill

## SDK (breaking, pre-GA)

- `generateDefaultFieldUniversalIdentifier` delegates to
`getFieldUniversalIdentifier` and now requires
`applicationUniversalIdentifier`
- Reverse default relation field identifiers are derived from the
field's real coordinates (standard object UID + actual field name, e.g.
`targetRocket` on `attachment`) instead of the legacy custom-object UID
+ synthetic `${fieldName}Inverse` hash input. Field *names* are
unchanged
- The manifest build threads the application universal identifier
through default field injection (two-pass over object configs)
- `twenty dev:add` now resolves the application universal identifier
upfront and refuses to scaffold anything until `defineApplication`
declares one — no more `fill-later` placeholder for the app UID in
generated files

## Upgrade

A 2.19 **workspace command** backfills existing
`fieldMetadata.universalIdentifier` rows to the deterministic
derivation. Coverage follows the ownership model:

- **The 8 system fields**: taken over for **every application**,
whatever value they currently hold. This is both safe and required now
that sync rejects non-derived values — leaving a row unconverged would
make its application unsyncable
- **`name`**: workspace-custom app → always taken over
(server-generated, no author to clobber); installed applications → only
rows still carrying the legacy SDK derivation are recomputed,
author-provided identifiers are never touched; standard app → never
touched (hardcoded in `STANDARD_OBJECTS`)
- **Default relation fields**: workspace-custom app → forward fields on
custom objects and reverse fields on the standard relation objects;
installed applications → legacy-derivation probe only

All identifiers of a workspace are updated inside a single transaction,
then the command flushes the field-metadata-related workspace caches and
bumps the metadata version.

Stored `applicationRegistration.manifest` snapshots are intentionally
**not** rewritten: installs and upgrades always sync from the
`manifest.json` inside the resolved package (npm/tarball), the stored
column is only used for display/marketplace purposes.

## Breaking behavior for old packages (fail closed)

Packages built with an older SDK carry legacy system field identifiers
in their tarball `manifest.json`. Installing or upgrading such a package
now fails with an explicit `INVALID_SYSTEM_FIELD` validation error
("universal identifier is not deterministic") instead of silently
mismatching against the backfilled rows and triggering a destructive
delete+create. The remediation is to rebuild the package with the new
SDK; the backfill has already converged the installed rows, so the
rebuilt manifest syncs cleanly.

## Test plan

- [x] `twenty-sdk` unit tests (526 tests) and typecheck
- [x] `twenty-shared` unit tests (1635 tests) including the
`STANDARD_OBJECTS` snapshot; `name` identifiers verified byte-for-byte
identical to `main`
- [x] Lint and typecheck clean on all touched packages
- [x] Integration: create a custom object and verify system + default
relation field identifiers match the deterministic derivation
(`create-one-object-metadata-deterministic-field-universal-identifiers`,
13 assertions passing)
- [x] Integration: `failing-sync-application-object-system-fields`
extended with a non-derived system field identifier case; all
identifiers in the spec pinned deterministically so snapshots embedding
expected/actual values are stable across runs (verified with a double
run)
- [x] Integration: all application sync suites pass with the derived
system field identifiers now required by the
`buildDefaultObjectManifest` test helper (9 suites, 20 tests)
- [x] Full test-database reset: standard app provisioning and seeded
workspaces pass the new validation
- [x] SDK manifest build verified on the postcard example app: all
auto-generated default field identifiers match the derivation
- [ ] Run
`upgrade:2-19:backfill-deterministic-field-universal-identifiers`
(dry-run then real) on a seeded workspace and verify identifier
convergence with a rebuilt app manifest
2026-07-06 13:34:33 +00:00
martmull 0706c7c1bc feat(front): upload files directly to storage for files-field, attachments and workflow (#22576)
## Context

Final step of the direct-to-storage upload work (follows #22449
endpoints, #22531 reaper, #22533 content-verify). The server can now
hand the client an upload URL so bytes go straight to storage instead of
being buffered through the Node process (the original OOM problem). This
PR switches the frontend to that flow for the three in-scope surfaces.

## What this does

Adds **`useDirectFileUpload`** — the shared hook that runs the
handshake:

1. `createFileUpload({ filename, size, fileFolder, fieldMetadataId? })`
→ `{ fileId, uploadUrl, contentType, expiresAt }`
2. `PUT` the raw file to `uploadUrl` with `Content-Type: contentType`
3. `completeFileUpload({ fileId })` → `FileWithSignedUrl` (`{ id, path,
size, createdAt, url }`)

Routes the three existing upload hooks through it, **keeping each hook's
public signature and return shape unchanged** so no call sites change:

| Hook | Folder |
|---|---|
| `useUploadFilesFieldFile` (FILES fields) | `FilesField` |
| `useUploadAttachmentFile` (attachments — the Attachment object's
`file` FILES field) | `FilesField` |
| `useUploadWorkflowFile` (workflow send-email attachments) | `Workflow`
|

Adds the `CreateFileUpload` / `CompleteFileUpload` gql documents and
regenerates `generated-metadata` types (+19 lines, scoped to the two new
operations).

## Out of scope

- AI-chat (`AgentChat`) and email-attachment (`EmailAttachment`) uploads
keep the legacy buffered mutations — those folders aren't in the
server's direct-upload allowlist (`[FilesField, Workflow]`).
- Workflow serverless-function code is saved via metadata mutations, not
the file path.

## Notes

- The legacy `uploadFilesFieldFile` / `uploadWorkflowFile` mutations
still exist server-side and remain used by the out-of-scope surfaces, so
this is non-breaking.
- Local storage routes the `PUT` to the token-authenticated streaming
endpoint (`SERVER_URL/file-upload/:id?token=…`); S3 uses a presigned
`PUT`. CORS is already enabled globally on the server and the token
rides in the query string (no cookies), so the browser upload works
cross-origin.

## Verification

`typecheck` and `lint:diff-with-main` green on `twenty-front`; codegen
ran against a live metadata schema so the generated file matches the
drift check. No existing tests/stories cover these hooks.

https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22576?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-06 15:28:57 +02:00
Raphaël Bosi 29920738dc Fix stale token race forcing re-login on verify pages (#22573)
Landing on /verify often forced users to refresh and log in again. The
culprit is a logout side effect triggered by a stale token: when a
previous session's token pair is still in localStorage, boot queries use
it, fail, and the failed token renewal reacts by logging the user out
(onUnauthenticatedError clears the token pair). That logout fires while
the loginToken exchange is running, so it can wipe the fresh session
that was just stored.

Fix: clear the stale token pair right before exchanging the loginToken
(in useVerifyLogin, so both /verify and /verify-email are covered) —
with no stale token to renew, the logout side effect never fires against
the new session. Also removes the redundant clientConfig gate on the
verify effect, stops that same logout side effect from redirecting users
off /verify-email mid-verification, and always re-enables app redirects
after loading the user.

Note: opening a loginToken link now replaces an existing valid session
instead of keeping it.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22573?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-06 14:58:32 +02:00
Raphaël Bosi 11edd56505 Don't list headless front components in the widget picker (#22578)
Front components can be marked headless (`isHeadless: true`), meaning
they render no UI and only run logic. Both the record-page and dashboard
widget pickers were listing every front component, including headless
ones, which have nothing to render as a widget.

This filters out headless front components where each picker reads them
from `FIND_MANY_FRONT_COMPONENTS`. The downstream select-item mapping,
keyboard-navigation list, and the "Front Components" group guard all
derive from that array, so filtering once excludes them everywhere and
hides the section when every front component is headless.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22578?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-06 12:20:05 +02:00
Raphaël Bosi 4cd05b2b3e Reverse pinned command menu item order on record header (#22577)
## Before
<img width="502" height="102" alt="CleanShot 2026-07-06 at 12 10 42@2x"
src="https://github.com/user-attachments/assets/da186911-5a1f-446f-a590-1af4a191554f"
/>

## After
<img width="500" height="102" alt="CleanShot 2026-07-06 at 12 10 17@2x"
src="https://github.com/user-attachments/assets/6e7bb914-55af-4968-a15a-fb17378fffc7"
/>

Pinned command menu items in the record page header rendered
left-to-right by position, putting the first item on the left. They
should read the other way: first item on the right, last on the left.

Fixed with `flex-direction: row-reverse` on the items container so the
reversal is purely visual. The DOM/source order stays in position order,
so the responsive overflow logic still keeps the highest-priority items
visible and keyboard/screen-reader order is unaffected.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22577?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-06 12:17:30 +02:00
neo773 904957ea1e message campaign redesign (#22508)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22508?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-05 13:44:34 +02:00
Thomas des Francs a4ed561e11 Add focus-safe side panel shortcuts (#22499)
## Summary

- Add side-panel-owned Escape and Backspace behavior for the side-panel
search input.
- Keep side-panel Escape scoped to side-panel focus and avoid
left-content fallback behavior.
- Add a Side Panel group to the keyboard shortcut menu.
- Reuse the side-panel focus id for AI chat thread-list shortcuts.


## Demo


https://github.com/user-attachments/assets/80a632d6-7ff7-496b-905f-a3f95f9cfc14

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-07-05 01:19:39 +02:00
martmull 4e43a0fb4e refactor(server): regroup application resolvers by resource and unify install permission flag (#22532)
Part of the app settings architecture cleanup
(twentyhq/core-team-issues#2456) — implements the API-surface regroup
Charles asked for in #20825 ("in application resolvers we have
uninstall, upgrade, findMany, etc. and for some reason install is part
of the marketplace, and they are not protected by same guards").

## Changes

**Resolver regroup by resource** (GraphQL operation names and signatures
unchanged):
- `installApplication` + `installMarketplaceApp` (deprecation preserved)
move from the marketplace resolver into
`application-install.resolver.ts`, next to
`findManyApplications`/`findOneApplication`/`uninstallApplication`.
- `uninstallApplication` moves from the manifest resolver into
`application-install.resolver.ts`.
- `runWorkspaceMigration` is deleted outright (unused — no consumer
anywhere in the repo, front/SDK/e2e/docs); the now-empty manifest
resolver is deleted. Its `AllMetadataName` GraphQL enum registration
moves to `collection-hash.dto.ts` (its remaining consumer).
- `generateApplicationToken` moves from the development resolver into
`application-oauth.resolver.ts` next to `renewApplicationToken`, keeping
its effective guards (`WorkspaceAuthGuard` +
`SettingsPermissionGuard(APPLICATIONS)`) and its token-bucket throttle
verbatim.
- `upgradeApplication` stays in the upgrade resolver (moving it into the
install resolver would create a module cycle — the upgrade module
imports the install module).
- Marketplace resolver now only holds catalog concerns:
`findManyMarketplaceApps`, `findMarketplaceAppDetail`,
`syncMarketplaceCatalog`.

**Permission unification** (the only behavior change):
`installApplication`, `installMarketplaceApp` and `upgradeApplication`
move from `MARKETPLACE_APPS` to `APPLICATIONS`, matching uninstall and
the find queries. Front-end install/upgrade button gating updated
accordingly (`SettingsApplicationDetails` /
`SettingsAvailableApplicationDetails`).

**Module wiring**: `MarketplaceModule` no longer imports
`ApplicationInstallModule` (only the moved resolver needed it);
`ApplicationInstallModule` now imports `MarketplaceModule` — no cycle.
Exception filters follow the moved operations
(`ApplicationRegistrationExceptionFilter` on the install resolver;
`ApplicationExceptionFilter` on the oauth resolver, which also fixes
`renewApplicationToken`'s previously unmapped FORBIDDEN).

**Codegen**: `twenty-client-sdk` metadata client regenerated for the new
schema ordering (pure reordering — no field changes); all front
`graphql:generate` configurations produced zero diffs.

## Explicitly kept (per review discussion)

`installMarketplaceApp` (deprecated) and `generateApplicationToken` are
kept for SDK back-compat despite having no current consumers.
`runWorkspaceMigration` was also consumer-less but, unlike those two,
had no back-compat rationale (not a deprecated alias, not a token
primitive), so it is removed rather than relocated.

## Deferred follow-ups (guard inconsistencies found in the audit,
intentionally NOT changed here)

- `findApplicationRegistrationByUniversalIdentifier` uses
`NoPermissionGuard` and returns the full registration entity, bypassing
the `API_KEYS_AND_WEBHOOKS` gate that `findOneApplicationRegistration`
enforces on the same data (SDK CLI `ensure-app-registration` depends on
it today).
- `upgradeApplication` alone requires `UserAuthGuard` — an API key can
install but not upgrade.
- `uploadAppTarball` (`MARKETPLACE_APPS`) and
`transferApplicationRegistrationOwnership` (`APPLICATIONS`) are
flag-inconsistent with the rest of registration CRUD
(`API_KEYS_AND_WEBHOOKS`).
- `syncMarketplaceCatalog` triggers an instance-wide job but is gated
only by a per-workspace settings flag.

## Verification

- `npx nx typecheck twenty-server` / `twenty-front` ✓;
`lint:diff-with-main` clean for both
- `npx jest "application"` in twenty-server: 30 suites / 154 tests
passed
- Server boots with the new module graph (DI verified at runtime);
codegen run against the live server
- Repo-wide grep: no remaining imports of the deleted manifest resolver

<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22532?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-07-05 00:24:12 +02:00
Thomas des Francs 59c16ef46f Polish settings billing and MCP UI (#22554)
## Summary

- Polish Billing credits progress rounding and secondary action styling.
- Update MCP setup logos, card spacing, grouping, and badge color.

## Before/After

MCP & APIs

<img width="2258" height="2010" alt="MCP & APIs settings visual"
src="https://github.com/user-attachments/assets/915c8b50-5b98-4ba9-8e5c-a33f36440f6e"
/>

Billing

<img width="2240" height="1644" alt="Billing settings visual"
src="https://github.com/user-attachments/assets/9e3eb332-5792-4a4a-80b0-1b984e574008"
/>
2026-07-04 23:27:40 +02:00
martmull ea851f7d9c feat: expose marketplace app detail fields explicitly and deprecate manifest blob (#22526)
Part of the application settings architecture work:
https://github.com/twentyhq/core-team-issues/issues/2456 — follow-up to
#22513.

`MarketplaceAppDetail` returned the entire `manifest` jsonb (100KB+)
over GraphQL and the front dug display fields and roles out of it. This
PR:

- Adds explicit fields to `MarketplaceAppDetail`: `description, author,
category, logo, websiteUrl, aboutDescription, termsUrl, emailSupport,
issueReportUrl, screenshots, defaultRoleUniversalIdentifier`, sourced
from the registration columns introduced in #22513, and `roles:
[MarketplaceAppRole!]` (full permission shape — the permissions tab and
install modal render object/field permissions), sourced from the
manifest at detail time.
- Marks the `manifest` field `@deprecated` (kept functional — removal
would be a breaking change).
- Front: the shared `marketplaceAppDetailFragment` no longer selects
`manifest`; display and role reads are flattened across
`SettingsAvailableApplicationDetails`, `SettingsApplicationDetails`, and
the share-link buttons. The three consumers that genuinely need deep
manifest structure (content-tab counts/`manifestContent`, permissions
objects, `useApplicationManifest` page-layout/view reads) use a scoped
`FindMarketplaceAppManifest` query until the manifest demotion PR
removes that need.
- Codegen regenerated where the documents live: front metadata config +
twenty-client-sdk metadata client (data/admin configs verified
untouched).

Verified: server+front typecheck, lint (0 warnings), server marketplace
suite 10/10, front marketplace/applications suites 41/41, live schema
introspection confirms the new fields and the deprecation.

<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22526?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>
2026-07-04 23:26:53 +02:00
Thomas des Francs 1a60d4eaa3 Add MCP setup screen (#22468)
## Summary

- Add a first-tab MCP setup experience under MCP & APIs with quick
install cards, manual configuration, client logos, and HTTPS gating for
Claude install links.
- Rename API/Webhooks settings surfaces to MCP & APIs and update related
icons, permissions, breadcrumbs, and command menu entries.
- Add the Tabler sparkle-2 icon wrapper and MCP setup visual assets.

## Screenshots

| Before | After |
| --- | --- |
| ![Before: APIs & Webhooks MCP
tab](https://gist.githubusercontent.com/Bonapara/f8a97d31fbc3cab2771d18cbacd53d4c/raw/5838d123bc3aae3df0d37507b3e69135bec86444/before-mcp-settings.png)
| <img alt="image"
src="https://github.com/user-attachments/assets/a6ae2ae6-322b-4370-b9b6-0a3d73ff7fa7"
/> |

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22468?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-07-04 16:38:59 +02:00
avonian 26491ecdc6 fix(front): don't blank record title when the title cell is untouched (#22293)
## Problem

On a record show page, the record title (label identifier) can be
silently blanked. Repro:

1. Create a record, set its name, set a relation field (e.g. a
one-to-one/many relation).
2. Reload the page.
3. Click the **edit** affordance on the relation field.

→ The record's name clears to “Untitled”, and it persists (an
`updateOne` fires with `input: { name: "" }`).

## Root cause

`RecordTitleCellTextFieldInput` registers `onClickOutside` / `onEnter` /
`onTab` / `onShiftTab` and always forwards `draftValue ?? ''` to be
persisted. When the title cell is in edit mode but the user never typed
in it, `draftValue` is `undefined`, so the forwarded value is `""`.

Opening another field's input counts as a click-outside on the title
cell, which then persists `{ name: "" }` over the existing label
identifier.

## Fix

Skip persisting when the title draft is untouched (`draftValue ===
undefined`) by passing `skipPersist` on the blur-style events. An
unedited title can no longer overwrite the existing label identifier;
genuine edits set the draft and persist exactly as before.

## Test plan

- Repro above: name no longer clears when editing a relation after
reload.
- Creating a new record and naming it still works.
- Renaming an existing record via its title still works.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22293?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-07-04 13:09:21 +00:00
Thomas des Francs e609320666 Squirclesssss 🟦🔵 (#22535) 2026-07-04 07:07:29 +02:00
Raphaël Bosi 566c3b6629 Remove v1 onboarding and rely only on v2 (#22398)
https://github.com/user-attachments/assets/a6bfaac3-6c79-4fd5-999a-e6a70cff8ac8


Removes the old (v1) signup and onboarding flow now that v2 is the only
path, and drops the `isOnboardingV2` flag entirely. The surviving
(formerly-v2) pages reclaim the canonical `AppPath` members and clean
URLs (`/welcome`, `/verify`, `/workspace-activation`, `/create/profile`,
`/sync/emails`, `/install-apps`, `/invite-team`, `/plan-required`).

- Deletes the v1 pages, the v1 workspace-creation form, the
`isOnboardingV2State` flag + `onboardingV2` URL-param plumbing, and
`InstallAppsAutoSkipEffect`.
- Collapses the router and page-change navigation matrix to a single set
of paths, and renames the v2 components/stories to drop the `V2` suffix.

Follow-up fixes so the single flow behaves correctly on every
deployment:

- Restore the captcha-token, query-param and pageview effects on the
default (root) domain, and serve `/authorize` there so OAuth login keeps
working.
- Gate the invite-team → `/plan-required` interception on billing so
billing-disabled instances aren't trapped on the upgrade page.
- On a cold boot to an auth/onboarding path, show the onboarding loader
instead of the CRM skeleton, and add `/verify-email` and
`/plan-required/payment-success` to that loader path list.
- Add a retry to PaymentSuccess after the confirmation timeout, fix the
InstallApps icon crossfade, restyle the book-call pages for the
full-page layout, and delete code orphaned by the v1 removal.
- Extract the pageview/captcha/query-param logic out of
`PageChangeEffect` into standalone Effect components shared by the root
and workspace app trees.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22398?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: Félix Malfait <felix.malfait@gmail.com>
2026-07-03 16:12:34 +00:00
Félix Malfait 9f4efa57ff Expose sent message identifiers in workflow send-email step output (#22520)
## Context

First step toward thread-continuity / follow-up email steps in workflows
(email sequences). The outbound send pipeline already knows the sent
email's RFC-822 Message-ID, the provider thread id, and the persisted
message/thread records — but none of it was surfaced in the send-email
step output, so a later step had no way to reference the email that was
sent.

## What changed

- `saveMessagesWithinTransaction` also returns a `messageExternalId →
messageThreadId` map, and `saveMessagesAndEnqueueContactCreation`
returns the message/thread id maps (both other call sites ignore the
return value)
- `SentMessagePersistenceService.persistSentMessage` and
`SendEmailService.persistSentMessage` return the persisted `{ messageId,
messageThreadId }` (`undefined` when persistence is skipped or fails —
sending still succeeds)
- `SendEmailTool` result now includes `headerMessageId`,
`threadExternalId`, `messageId` and `messageThreadId`
- SEND_EMAIL step output schema (server + frontend) declares
`headerMessageId`/`messageId`/`messageThreadId` so they show up in the
variable picker; DRAFT_EMAIL keeps its success-only schema since draft
creation returns no identifiers yet

This already enables manual thread continuity today: wire
`{{sendEmailStep.headerMessageId}}` into a later email step's
In-Reply-To advanced field — the composer resolves the References chain
and provider thread from it.

## Tests

- New `send-email-tool.spec.ts` covering identifiers in the result,
persistence disabled, and persistence failure
- Extended save-messages spec with the new map, updated frontend
`computeStepOutputSchema` tests

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22520?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-03 18:01:40 +02:00
Félix Malfait 3cf04bea28 Support sender variable on workflow send-email action (#22512)
## Context

Draft-email workflow steps already accept a workspace member variable as
the sender: the step input's `connectedAccountId` can hold a workflow
variable that resolves to a workspace member id, which the action then
maps to that member's first connected account. Send-email steps were
gated out of this and only accepted a static connected account pick.

This enables the same dynamic sender resolution on send-email, e.g.
sending from the assignee/owner of the record that triggered the
workflow.

## What changed

- Removed the draft-only gate in
`EmailWorkflowActionBase.postprocessInput` so send-email resolves a
workspace member id to a connected account the same way draft-email does
- Exposed the variable picker and hint on the Account field for both
email actions in the workflow step editor
- Updated the send-email action spec to cover sender resolution (mirrors
the draft-email spec) and replaced the `SendEmailHasNoVariablePicker`
story with a variable-sender story for `SEND_EMAIL`

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22512?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-03 17:53:07 +02:00
Félix Malfait 13f380b80d perf(front-component): fingerprint built-JS URLs by path for CDN caching (#22530)
**Stacked on #22523** — base is that branch, so the diff shows only this
commit. GitHub will retarget it to `main` automatically once #22523
merges.

Follows up on @FelixMalfait's question on #22523: move the
BuiltFrontComponent cache key from a query string into the path so it
plays well with Cloudflare cache rules.

## What

- URL: `/rest/front-components/:id?checksum=<c>` →
`/rest/front-components/:id/<c>.js` (`getFrontComponentUrl`).
- Route: the controller now accepts `[':frontComponentId',
':frontComponentId/:cacheKey']`. `:cacheKey` is a pure cache-buster the
server **ignores** — it still resolves by `:frontComponentId`, exactly
as the query param did.

## Why a path segment (not `:id-<checksum>.js`)

A path-based, extension-bearing URL is matched by Cloudflare's
**default** static-asset caching and by trivial `*.js` path cache rules,
and it's immune to any "ignore query string" cache setting that would
otherwise collapse `?checksum=` to one entry and serve stale JS.

I used a path **segment** (`/:id/:checksum.js`) rather than the literal
`:id-<checksum>.js` you sketched because the id is a **UUID — which
itself contains hyphens** — so a `-` separator is ambiguous to parse. A
segment is unambiguous and equally CDN-friendly (still ends in `.js`).

## Backward compatibility

The bare `:frontComponentId` route is kept, so URLs minted before this
deploys (query-string form, or in-flight pages) still resolve. It can be
dropped in a later release once no client mints the old form. No data
migration — the URL is computed at render time from `frontComponentId` +
`builtComponentChecksum`.

## ⚠️ Decision for you: this alone does not edge-cache — `private` vs
`public`

BFC is served behind `WorkspaceAuthGuard` and #22523 set its header to
**`private`**, max-age, immutable. `private` means shared caches
(Cloudflare) **won't** store it — so today this is browser-cache only,
and the path change just makes it *ready* for edge caching + clean cache
rules.

To actually get **edge** caching you'd additionally either flip BFC to
`public` or add a Cloudflare rule that overrides cache-control — which
means **accepting that the `id`+`checksum` URL becomes the access
capability** (a cache hit is served without re-checking origin auth).
The cache key is unique per component+build so there's no
cross-workspace mixup, but the built JS effectively becomes
public-by-URL (same posture PublicAsset already has). I've **left it
`private`** here; flipping to `public` is your call and can be a
one-line follow-up.

## Tests

- `getFrontComponentUrl` unit test: fingerprinted path when a checksum
is present, bare fallback otherwise.
- Integration test: the `/front-components/:id/:checksum.js` path serves
the built JS with `Content-Type: application/javascript` and
`Cache-Control: private, max-age=86400, immutable`. Existing bare-route
tests remain and still pass.

https://claude.ai/code/session_01AKwhTxYFDhWhCZ4b7sf35W

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22530?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-03 17:17:14 +02:00
Etienne 1270054d35 feat(ai): dashboard & view building (#22411)
## Why

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

## What changed

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

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

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

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

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

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

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

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22411?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 14:15:27 +00:00
martmull 1a85b88d38 feat(files): direct-to-storage upload endpoints with pending file lifecycle (#22449)
<img width="1484" height="404" alt="image"
src="https://github.com/user-attachments/assets/b2d363bf-d9e1-49fb-9811-8cc98041aa79"
/>


## Context

Uploading large files currently OOMs the server: every upload resolver
buffers the whole file in memory (`streamToBuffer`) before writing it to
storage. This PR is the first of a series introducing direct
client-to-storage uploads. It adds the server-side endpoints and driver
support only — it is non-breaking and nothing consumes the new flow yet.
Follow-up PRs will migrate the frontend upload paths, add a
stale-pending-file cleanup cron, and cap the legacy buffered resolvers.

## What it does

**New upload flow (initiate → PUT → confirm):**

- `createFileUpload(filename, size, fileFolder, fieldMetadataId?)`
validates the request (folder allowlist: `FilesField`/`Workflow`, max
size, extension-derived mime type), creates the file record in a new
`PENDING` status, and returns an upload target:
- **S3 with presign enabled** → a presigned PUT URL with
`Content-Type`/`Content-Length` pinned in the signature, so the client
uploads straight to the bucket;
- **local storage, or S3 without presign** → a token-authenticated
streaming endpoint on the server (`PUT /file-upload/:id?token=…`, new
`FILE_UPLOAD` JWT type) that pipes the request body to the storage
driver with constant memory usage and a declared-size cap.
- `completeFileUpload(fileId)` verifies the bytes actually landed in
storage (HEAD + size match against the declared size) and flips the
record to `UPLOADED`. Idempotent.

**Pending lifecycle safety:**

- New `status` column on `core.file` (`PENDING`/`UPLOADED`, default
`UPLOADED` so all existing rows and the legacy upload path are
unaffected) + fast instance command.
- Files are refused by the serving endpoints and by FILES-field sync
while `PENDING`.

**Driver support (both drivers):**

- `getPresignedUploadUrl` (S3: presigned PUT; local: `null` →
server-endpoint fallback)
- `writeFileStream` (local: `fs` pipeline with the existing
symlink/containment hardening, partial-file cleanup on error; S3:
`@aws-sdk/lib-storage` `Upload` for bounded-memory streaming)
- `getFileMetadata` (HEAD/stat for confirm-time verification)

## Tests

- `file-upload.service.spec.ts`: initiate validation (folder allowlist,
size), presigned vs fallback target, confirm verification (missing
object, size mismatch, happy path, idempotency)
- `local.driver.spec.ts`: `writeFileStream` (content, symlink rejection,
partial-file cleanup on stream error), `getFileMetadata`
- `s3.driver.spec.ts`: `getPresignedUploadUrl` (disabled → null, PUT
command with signed content-type/content-length)
- `direct-file-upload.integration-spec.ts`: full end-to-end flow against
the local driver (initiate → PUT → complete → download), plus error
paths (complete without upload, oversized PUT → 413, invalid token →
403, unsupported folder, size above max)

## Notes for reviewers

- The upload-size ceiling for direct uploads is
`settings.storage.maxDirectUploadFileSize` (1GB), separate from the 10MB
`maxFileSize` used for pictures.
- Since content can't be sniffed before it reaches storage, the mime
type is derived from the file extension (with the existing
`TWENTY_MIME_POLICY` override) and unknown extensions fall back to
`application/octet-stream`; the serving path already forces
`Content-Disposition: attachment` for anything not on the inline-safe
allowlist.
- Self-hosters using S3 presign will need a bucket CORS policy allowing
`PUT` from the frontend origin (config variable description updated).

https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22449?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-03 15:11:53 +02:00
Félix Malfait 8b191d6fcc chore(server): remove the five dead FileFolder values and their legacy serving pipeline (#22516)
Follow-up cleanup after #22510: shrink `FileFolder` and
`fileFolderConfigs` to only folders that actually exist, so per-folder
policy entries are real decisions.

## What

**Remove the five dead enum values** — `ProfilePicture`,
`WorkspaceLogo`, `Attachment`, `PersonPicture`, `File`. They were
already marked replaced/removed in the enum, have no production write
path, and `FileByIdGuard`'s `SUPPORTED_FILE_FOLDERS` allowlist already
rejects them at the serving endpoint.

**Delete the legacy path-based serving pipeline that existed only for
them** — verified wired to no route:
- `FilePathGuard` — registered as a provider in `FileModule` but applied
to no controller
- `extractFileInfoFromRequest` (parsed the old
`/files/profile-picture/original/TOKEN/file.jpg` format) — only consumer
was `FilePathGuard`
- `checkFileFolder` — only consumer was `extractFileInfoFromRequest`
- `settings.storage.imageCropSizes` — keyed exclusively by the three
dead picture folders, zero consumers
- the crop-size helpers in `utils/image.ts` (`getCropSize`,
`ShortCropSize`, `CropSize`) — zero consumers outside the file;
`getImageBufferFromUrl` is kept
- `AllowedFolders` type — last consumer was `checkFileFolder`

**Test fixtures** referencing dead folders were moved to living ones;
the specs of deleted utils are deleted with them.

**Generated files** (`twenty-front/src/generated-metadata/graphql.ts`,
`twenty-client-sdk` schema) hand-updated to match the shrunk GraphQL
enum.

## Legacy data safety

Workspaces may still hold `File` rows whose `path` starts with a dead
prefix (e.g. `attachment/…`). These stay inert, exactly as today:

- Serving: `FileByIdGuard` rejects non-supported folders before any
config lookup, and file lookups filter by `path LIKE
'<current-folder>/%'`, so dead-prefix rows are unreachable.
- Every consumer that feeds stored paths into
`removeFileFolderFromFileEntityPath` (which throws on unknown prefixes)
is upstream-guarded by a current-folder filter or allowlist — audited
all seven call sites.
- Stored legacy member `avatarUrl` strings are parsed with
`extractFileIdFromUrl(url, FileFolder.CorePicture)` and already fall
back to `''` for old formats; unchanged.

## GraphQL note

`FileFolder` is exposed as a GraphQL enum (input of the dev-only
`uploadApplicationFile` mutation, which only accepts application-code
folders). Clients sending a removed value were already rejected at the
resolver allowlist; they now fail GraphQL enum validation instead. No
supported client sends them — the frontend only uses `CorePicture`.

Net: **+10 / −301** across 17 files.

https://claude.ai/code/session_01AKwhTxYFDhWhCZ4b7sf35W

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22516?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-03 13:13:14 +02:00
martmull af1b89c788 feat(server): import application logo into file storage at install (#22437)
Installed apps stored the logo as the manifest's relative path but never
imported the file, so the public-assets URL 404'd and logos went missing
in the UI for npm/tarball sources. Import the logo (best-effort — a
declared but unshipped logo is skipped, not fatal) and record it as a
first-class logoFileId on the application so it can be served reliably.



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22437?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-03 11:47:58 +02:00
Thomas des Francs 1db9b0c657 Fix webhook entity dropdown layout (#22497)
## Summary
- Reuse the shared `SelectControl` for the webhook entity selector.
- Update webhook entity menu icons and object ordering.
- Fix dropdown section labels and separators so headers span full width
while menu items keep the expected inset and 4px header spacing.
- Keep the webhook filter row responsive and the remove-filter button at
icon-button width.

## Before/After
<img width="3454" height="2000" alt="image"
src="https://github.com/user-attachments/assets/bfe0cc39-ad66-4cae-98be-1eddc66f12f3"
/>

![Webhook entity
dropdown](https://gist.githubusercontent.com/Bonapara/42ae4270ead4791e53636b728fb4a284/raw/d3b89c7d19cb7dd10d237a3279e93a98169177c6/webhook-entity-dropdown.svg)

## Tests
- `git diff --check`
- `npx nx typecheck twenty-front`


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22497?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-03 11:03:41 +02:00
martmull 25fe66565c feat(applications): add type and options to application variables (#22157)
## Before
<img width="1452" height="709" alt="image"
src="https://github.com/user-attachments/assets/cd384ffa-cbe6-49d5-a807-ca8d580f55a9"
/>

<img width="1074" height="452" alt="image"
src="https://github.com/user-attachments/assets/720d38db-3495-4032-8831-17d24ec6a7e7"
/>

## After

<img width="1421" height="865" alt="image"
src="https://github.com/user-attachments/assets/2275c996-c895-4800-8324-2aa2ddfddd43"
/>

<img width="1348" height="870" alt="image"
src="https://github.com/user-attachments/assets/3e1a891d-6db0-4cbd-870a-2a5bbde4929d"
/>


## Summary

Adds typed application variables with optional select **options**. This
is the other half of #22059, split out from the custom-settings-tab
removal.

## Changes

- **Shared types**: `ApplicationVariable` / `ServerVariables` gain an
optional `type` (a `FieldMetadataType` subset — `TEXT`, `BOOLEAN`,
`NUMBER`, `DATE`, `SELECT`, `MULTI_SELECT`, `RAW_JSON`, `RICH_TEXT`,
`ARRAY`, …) and select `options`. New
`serializeApplicationVariableValue` /
`deserializeApplicationVariableValue` helpers convert typed values
to/from the encrypted string storage.
- **Server**: `type`/`options` columns on `applicationVariable` and
`applicationRegistrationVariable` (entities + DTOs), a fast `2-17`
instance command, manifest processing via the serialization helpers, and
a `QueryDeepPartialEntity` cast where the manifest JSON column is
persisted.
- **Frontend**: a polymorphic `SettingsApplicationVariableInput` that
renders the native `Form*` field component for each type (boolean,
number, date/date-time, select, multi-select, array, raw JSON, rich
text, text); fragment/query updates to fetch `type`/`options`.
- **SDK**: `defineApplication` validates that `SELECT`/`MULTI_SELECT`
variables declare non-empty `options` at build time (since `options` is
kept structurally optional for TypeORM/SDK compatibility).

Variables default to `TEXT` when no type is given, so existing manifests
are unaffected.

## Notes

The generated GraphQL artifacts (`type`/`options` on the variable types)
are regenerated by codegen; that change accompanies this PR.

https://claude.ai/code/session_013Z7UB35V2mvUozh55QHG23

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22157?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-03 10:52:22 +02:00
Charles Bochet caa282f5da fix(front): gate SSO and audit logs on enterprise validity token (#22459)
## Problem

The Security settings UI gates the **SSO** card and **audit logs** on
`currentWorkspace.hasValidSignedEnterpriseKey`, but the backend enforces
enterprise features through `EnterprisePlanService.isValid()`, which
returns `hasValidEnterpriseValidityToken()`.

These check different things:

- **`hasValidSignedEnterpriseKey`** — the `ENTERPRISE_KEY` config var is
present and is a correctly-signed JWT (signature check only, no expiry).
It's the credential the operator installs.
- **`hasValidEnterpriseValidityToken`** — a validity token (fetched from
the licensing API, stored as an `AppToken`, refreshed by cron) that is
present and not expired. This is the runtime "is enterprise active right
now" check, and it's the one every backend gate uses
(`EnterpriseFeaturesEnabledGuard`, SSO sign-in, event-log retention,
billing, row-level permissions, signing-key rotation).

As a result, a workspace with a valid unexpired validity token but no
locally-signed key (e.g. the `ENTERPRISE_KEY` env var isn't set on a
given replica, or an "orphaned validity token" state) shows SSO
**disabled** in the UI while the server would actually authorize SSO
operations.

## Change

Gate the SSO card and audit-logs section on
`hasValidEnterpriseValidityToken` so the UI matches backend enforcement.

The Enterprise management page (`SettingsEnterprise.tsx`) deliberately
keeps the key/token distinction — it needs the signed key for
subscription status, the customer portal, and the
orphaned-validity-token warning — so it is left unchanged.

## Files

- `SettingsSSOIdentitiesProvidersListCard.tsx` — skip/disable now driven
by the validity token
- `SettingsSecuritySettings.tsx` — `hasEnterpriseAccess` now driven by
the validity token

## Notes

- The SSO `skip` condition changed from `=== false` to `!== true`, so an
undefined workspace (still loading) now skips the query rather than
firing it — consistent with the `disabled` checks below it.

## Test

- `nx lint:diff-with-main twenty-front` passes.
- Not manually verifiable in local dev without an enterprise license
setup (requires a workspace in the token-valid / key-absent state).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22459?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-03 10:42:06 +02:00
Thomas des Francs 416f4cf90e Add billing plans comparison page (#22424)
## What changed

- Added a Billing > Plans tab with a Pro vs Organization comparison
table.
- Updated subscription card CTAs so Compare plans routes to the new
Plans tab, while upgrade/downgrade actions stay inside the comparison
page.
- Added a reusable segmented control and used it for the billing period
toggle and navigation drawer tabs.
- Hid billing pages/navigation when billing is disabled, including
self-hosted environments.

<img width="1417" height="882"
alt="file-f98283057b5a700f275cde2a38831ac3"
src="https://github.com/user-attachments/assets/182a7ff4-51fa-492e-8c75-51f9dc35b59e"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22424?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: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-07-03 08:30:43 +00:00
Félix Malfait 3aeb2b0d5d Self-host Inter and DM Mono fonts (#22506)
Replaces the two Google Fonts stylesheets with self-hosted fonts via
`@fontsource`, imported in the app entry (and Storybook preview), and
removes the duplicate Inter that BlockNote was shipping.

## Why

- **The caching argument for Google Fonts is dead.** Browsers partition
the HTTP cache by top-level site (Chrome 86+, Firefox 85+, Safari even
earlier), so a font cached from another website is never reused on ours.
Every first-time visitor downloads the fonts either way — Google just
adds a detour.
- **Faster first paint.** This removes two render-blocking cross-origin
stylesheets from `index.html` (DNS + TLS to `fonts.googleapis.com`, then
a second connection to `fonts.gstatic.com`, with no preconnect today).
The fonts now ship from our own `/assets` alongside the rest of the app,
behind the same CDN and cache policy.
- **Privacy.** Visitor IPs are no longer sent to Google on every page
load. A German court ruled in 2022 that Google Fonts embedding violates
GDPR, and privacy-conscious self-hosters currently have no way to opt
out of the dependency.
- **Air-gapped / offline self-hosted instances** currently render
fallback system fonts; they now get the real ones.

## Font unification

We were actually loading Inter from two places: the Google stylesheet,
plus `@blocknote/core/fonts/inter.css` (8 weights, latin-only,
woff+woff2) imported by the two rich-text editors — whichever loaded
last won the cascade. Both are gone; `@fontsource` is now the single
source:

- Inter 400/500/600 (theme weights) + 700 (rich-text bold, previously
only covered by the BlockNote copy)
- DM Mono 400/500 (DM Mono has no 600 upstream; the old Google link
requested one anyway)

Fontsource ships the same `unicode-range` subsets as the Google CSS, so
browsers still only download the subset they need (~60KB of woff2 for
latin), and non-latin locales keep full coverage — which the latin-only
BlockNote copy didn't provide.

## Notes

- The BlockNote PDF export (`exportBlockNoteEditorToPdf.ts`) still
fetches Inter TTFs from `fonts.gstatic.com` at export time — react-pdf
needs TTF files, left unchanged here.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22506?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-03 10:12:27 +02:00
Félix Malfait d8ea406b80 fix(front): skip unknown fields in SSE optimistic updates instead of dropping the event (#22474)
## Rationale

When an SSE record-update event carries a field the tab's metadata cache
doesn't know (someone added a custom field after this tab loaded),
`computeOptimisticRecordFromInput` throws `Should never occur,
encountered unknown fields …`. The catch in
`useTriggerEventStreamCreation` swallows the throw, so the **entire
event is discarded** — the tab silently stops reflecting that update.

**Production evidence (Sentry):** the `Error while processing SSE
message` family — ~860 events / ~480 users in the last 30 days, ongoing
([TWENTY-FRONT-7PD](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-7PD)
et al.), with the sampled stack landing exactly on this throw. Related:
[TWENTY-FRONT-633](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-633)
(903 users).

## Why this is the root cause, not a symptom patch

The throw is an assertion that unknown fields "should never occur".
That's the correct contract for the 4 local-mutation callers
(`useUpdateOneRecord`, `useCreateOneRecord`, `useCreateManyRecords`,
`useRunWorkflowVersion`) — there, an unknown field is a programming bug.
But for the SSE caller the input comes from the **server**, which can
legitimately be ahead of the tab's metadata. Schema convergence is the
metadata-event pipeline's job (it flows over the same SSE channel); the
record pipeline's job is to tolerate the window. So the fix moves the
decision to the right caller instead of weakening the assertion for
everyone:

- `getUnknownRecordInputFields` — detection logic extracted, shared
- mutation callers: still throw (behavior unchanged)
- SSE update path: filters unknown fields and applies the rest of the
event

Dropping the *fields* loses nothing: the tab couldn't render them anyway
without the metadata, and the metadata event that follows triggers the
proper refresh.

## User impact

~480 users/month currently get silently stale tabs (list/kanban rows not
reflecting teammates' updates) whenever any custom field is added while
they have Twenty open. After this fix, updates keep flowing; only the
not-yet-known field is skipped until metadata converges.

## Test plan

- [x] Unit tests for `getUnknownRecordInputFields` (known fields,
`__typename`, unknown fields, relation join columns)
- [x] Existing `computeOptimisticRecordFromInput` tests cover the
unchanged throw path
- [ ] CI green

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22474?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-03 09:31:42 +02:00
Abdul Rahman 429e8c4b84 fix: align email validation between front and server and roll back optimistic value on failed save (#22490)
## Summary

Inline edits of EMAILS fields could leave the UI in a misleading state:
the frontend validated with Zod's default `z.email()` while the server
used the stricter `z.regexes.unicodeEmail` pattern (which caps the local
part at 64 characters). A very long email passed client validation and
was optimistically written to the UI; the server then rejected the
mutation. An error snackbar was shown, but the field kept displaying the
unsaved value until a page reload.

## Changes

- **Single source of truth for email validation**: added a shared
`emailSchema` (`z.email({ pattern: z.regexes.unicodeEmail })`) in
`twenty-shared/utils`, now used by:
- the server-side EMAILS field validator
(`validate-emails-primary-email-subfield-or-throw.util.ts`)
  - the `EmailsFieldInput` inline editor
  - spreadsheet import validation
- **Rollback on failed save**: `useUpdateOneRecord` now restores the
optimistically updated fields in the record store when the mutation
fails, mirroring the store upsert already done in the success path.
Previously the catch block only rolled back the Apollo cache — which
stopped reverting the UI after table virtualization, since the record
store (the render source of truth) is no longer synced reactively from
the cache. The error is still rethrown, so the existing global
promise-rejection handler keeps showing the error snackbar. This fixes
the stale-value-until-reload behavior for all field types and all
callers, not just EMAILS fields.
- **Regression tests**: added unit tests for the shared schema,
including the >64-character local part case.

Fixes [sonarly issue
#54034](https://sonarly.com/issue/54034?share=eyJ0aWQiOjMzMCwidHlwIjoiYnVnIiwicmlkIjo1NDAzNCwiZXhwIjoxNzgzNTI1OTQzfQ.9e7639034a677301512fceeafab764b1)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22490?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-03 09:18:09 +02:00
Félix Malfait 86e4685781 fix: format numbers according to user preferences in settings views (#22501)
## Context

Record and field counts in several settings views were rendered as raw
numbers (e.g. `158355`), ignoring the workspace member's number format
preference. This PR routes them through the existing `useNumberFormat`
hook (which wraps `formatNumber` with the user's `numberFormat`
preference), so they render as `158,355` / `158 355` / `158.355` /
`158'355` depending on the preference.

## Changes

Starting point (from the screenshot): the Settings → Data model objects
table.

- **`SettingsObjectItemTableRow`** — Fields and Records columns in
Settings → Data model
- **`SettingsAvailableStandardObjectItemTableRow`** — Fields column in
Settings → Data model → New object
- **`SettingsDataModelOverviewObject`** — record count next to the
object name in the data model graph overview (guards against `undefined`
while the count query loads)
- **`SettingsLogs`** — "X of Y" record counts above the event logs table
- **`SettingsAdminGeneral`** — Users column in the admin panel top
workspaces table
- **`SettingsAdminWorkspaceContent`** — Members value in the admin panel
workspace info card
- **`NoteList`** — total notes count in the record page Notes tab

## Test coverage

- `npx nx lint:diff-with-main twenty-front` (oxlint + oxfmt) passes
- `npx nx typecheck twenty-front` passes
- No behavioral change beyond formatting; `formatNumber` defaults to 0
decimals so integer counts stay integers

https://claude.ai/code/session_019pXXZSaza8TXPYK8NefQiS

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22501?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-03 08:28:51 +02:00
Félix Malfait 087bee0036 fix(ai): notify all tabs when a pending question is answered (#22491)
## Rationale

`resolvePendingQuestion` updates the question tool-part to `answered`
and re-claims the thread — but publishes **nothing**. The answering tab
converges via a local browser event; every other tab keeps rendering the
question card as interactive until the resumed stream's first chunk
happens to arrive. A second tab (or teammate view on shared context) can
attempt to answer an already-answered question and hit a confusing
`QUESTION_NOT_PENDING` error.

## Why this is the root cause, not a symptom patch

Answering a question is a state transition every subscriber cares about
— exactly like queue promotion, message persistence, and stream errors,
all of which publish. This transition just never did. The fix publishes
the existing refetch-trigger event (`queue-updated`, which every tab
already handles by refetching messages + thread state) right after
resolution — no new event type, no new client code path, consistent by
construction with how every other transition converges tabs. A dedicated
`question-answered` event carrying the answers would save one refetch
round-trip; the audit's verdict was that's over-engineering for a rare
interaction.

Publishing *before* the resume-enqueue is deliberate: even if the
enqueue fails, the question **is** answered server-side, and tabs should
reflect server truth.

## User impact

Second tabs stop offering an interactive question that will error when
submitted; everyone sees the answered state within a refetch instead of
whenever the stream resumes.

## Test plan

- [ ] CI green
- [ ] Manual: two tabs on one thread, answer the question in tab A → tab
B's card flips to answered without interaction

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22491?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-02 21:30:34 +02:00
Félix Malfait a505ed3245 feat(ai): typed CONTEXT_WINDOW_EXCEEDED error that hides the pointless Retry (#22488)
## Rationale

When message pruning can't fit the conversation into the model's context
window, `chat-execution.service.ts` throws a **raw `Error`**.
`mapErrorToStreamError` classifies it as generic
`STREAM_EXECUTION_FAILED`, so the client renders a standard failure with
a **Retry button that deterministically fails again** — the conversation
doesn't get shorter by retrying. Users loop on Retry against a
permanently-failing thread.

## Why this is the root cause, not a symptom patch

The failure is *terminal for the thread by construction*, and the error
channel already distinguishes terminal-vs-retryable via typed
`AiExceptionCode`s — this failure just never got one. Adding
`CONTEXT_WINDOW_EXCEEDED` (typed exception → `UserInputError` mapping
instead of a 500 → both error surfaces render the start-a-new-thread
message without `onRetry`) puts it on the same rails as
`API_KEY_NOT_CONFIGURED` and the other special-cased codes. Both
frontend error surfaces route through `AiChatErrorRenderer`, so one case
covers the in-message and under-list renderings.

The deeper endgame (auto-summarize/compact older turns so threads never
brick) is a multi-week feature — and this typed error remains necessary
even then, as its terminal fallback.

## User impact

Instead of an opaque error and a Retry that never works, users hitting
the context limit get told exactly what happened and what to do (start a
new thread), and monitoring stops counting a user-condition as a server
error.

## Test plan

- [ ] CI green
- [ ] Manual: fill a thread past the model limit → typed message, no
Retry on either error surface

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22488?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-02 21:26:41 +02:00
Félix Malfait ab4e979352 perf(ai): render streaming markdown as memoized blocks (#22489)
## Rationale

`LazyMarkdownRenderer` re-parses and re-renders the **entire accumulated
message** through react-markdown on every throttled stream flush (10/s).
Render cost grows linearly with message length while streaming, so long
answers degrade progressively — this is the dominant jank vector in the
chat (verified in the perf audit: no memoization anywhere in the
message-render path).

## Why this is the root cause, not a symptom patch

The waste is structural: 99% of a streaming message is settled text that
cannot change, yet it re-renders because the whole string is one
react-markdown call. Splitting at real markdown block boundaries via
`marked.lexer` (already a dependency, used in the advanced text editor)
and memoizing per block means settled blocks keep their rendered
subtree; only the growing tail block re-parses per flush — cost becomes
O(tail) instead of O(message). Index keys are stable because streaming
is append-only. This is the standard memoized-markdown pattern from the
AI SDK ecosystem.

Deliberately **not** included: list virtualization for very long
threads. The audit's verdict was memoize first, virtualize only if
profiling still shows mount cost matters — virtualization changes scroll
behavior and deserves its own evaluation.

One known tradeoff: markdown reference-style links whose definition
lives in a *different* block won't resolve across blocks. Model output
uses inline links; the tradeoff is shared by every implementation of
this pattern.

## User impact

Long streaming answers stop stuttering — keystroke-to-paint stays flat
instead of degrading as the answer grows. Most noticeable on tool-heavy
turns that produce big final summaries.

## Test plan

- [ ] CI green (existing markdown rendering covered by storybook visual
tests)
- [ ] Manual: stream a long answer with code fences and tables —
identical rendering, no per-flush jank in the profiler

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22489?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-02 21:25:09 +02:00
Félix Malfait 4a8679327b fix(ai): bound silent stream recovery and surface a terminal CONNECTION_LOST state (#22486)
## Rationale

Two gaps in the keep-alive recovery (`AgentChatStreamKeepAliveEffect`):

1. It only engages when `isStreaming` is already true — a socket that
dies **before the first chunk** leaves the user waiting forever with no
recovery path (CONFIRMED-high in the chat-stack audit; the window where
Sentry shows failures concentrate).
2. When it does engage, it retries **silently forever** — a genuinely
dead connection means an infinite spinner with the user none the wiser.

## Why this is the root cause, not a symptom patch

Recovery must be gated on "a response is owed" — which since #22485 is
`isStreaming || isAwaitingFirstChunk`, closing gap 1 with the state that
actually models the window rather than a timer heuristic. For gap 2,
unbounded retry hides a terminal condition; the fix is an honest state
machine: 3 silent recoveries (resubscribe + refetch), then a client-only
`CONNECTION_LOST` error. Two deliberate choices from the audit:

- **No Retry button** on `CONNECTION_LOST` — it's semantically forced,
not cosmetic: Retry calls `retryLastFailedTurn`, which requires a
persisted `lastStreamError`; after a mere connection loss the server has
no failed turn (the stream is likely still running or completed
server-side), so Retry would deterministically throw
`NO_FAILED_TURN_TO_RETRY`.
- **Auto-clear instead of dead-end**: the moment events flow again (SSE
reconnect, refetch delivering data), the `CONNECTION_LOST` error clears
itself — the state is "connection lost", not "turn failed", and it
self-heals when the connection returns.

## User impact

A dead connection pre-first-token currently means waiting forever;
mid-stream it means silent infinite recovery. Now: three quiet recovery
attempts (which fix the transient cases invisibly), then a truthful
message, which disappears on its own when connectivity returns — and the
server-side answer is intact all along, delivered by the next successful
refetch.

## Stack

Based on #22485 (pending indicator) — reads the awaiting-first-chunk
state. Chain: #22484#22485 → this.

## Test plan

- [ ] CI green
- [ ] Manual: kill the network pre-first-token → 3 recoveries →
CONNECTION_LOST; restore network → error clears, transcript catches up

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22486?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-02 21:20:45 +02:00
Félix Malfait 77529191f8 fix(ai): apply stream chunks in exact server seq order via a client-side sequencer (#22484)
## Rationale

Stream chunks reach the client on two unsynchronized paths: live SSE
events and the catchup replay (fired on reload, refetch, SSE reconnect,
and keep-alive recovery). The server already stamps every chunk with an
authoritative `seq` (Redis `RPUSH` length), but the client applies
chunks in **arrival order**. Reload mid-stream and the two paths
interleave: duplicated text deltas, or lower-seq catchup chunks applied
after higher-seq live ones — the streaming answer visibly garbles until
the persist-refetch repaints it.

Main's existing guard (`seq < firstLiveSeq` bound on catchup) only
prevents duplication in one direction (live-before-catchup); it does
nothing for catchup-during-live overlap, and it *creates* a
dropped-chunk window when chunks land between the catchup snapshot and
the first live event.

## Why this is the root cause, not a symptom patch

The defect is a joining problem between two ordered sources, and the
join point is the client — the server can't fix it without a protocol
change (per-subscriber cursor resume), because Redis pub/sub fan-out has
no per-subscriber replay. Given the transport, the correct fix is to
make the reducer's input **seq-exact**: apply strictly in server order,
dedup anything already applied, buffer early arrivals until the gap
fills. Escalation is bounded and degrades gracefully: a stalled gap
triggers one refetch (the full-list catchup replay doubles as gap-fill,
no new endpoint), a second stall flushes the buffer in order — so even
an expired chunk list degrades to slightly-lossy instead of wedging. The
catchup path now replays the full list (the sequencer dedups overlap),
which also closes the dropped-chunk window.

Server-side cursor resume remains the nicer long-term protocol (would
simplify this client), but it's a subscription protocol change; this
fixes the user-facing defect with zero server change and is
forward-compatible with it.

## User impact

Reloading (or losing the connection) mid-answer currently scrambles or
duplicates the streaming text until the turn completes. With this, the
answer renders identically no matter when you reload or how the two
delivery paths race.

## Test plan

- [x] Sequencer unit suite (fake timers): in-order apply, out-of-order
buffering, catchup/live overlap dedup, gap-fill via replay,
stall→refetch escalation, second-stall in-order flush, high-water-mark
continuation, reset
- [ ] CI green
- [ ] Manual: reload mid-stream repeatedly; text never reorders

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22484?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-02 21:17:11 +02:00
Félix Malfait 310742519b fix(ai): tell members without billing permission why AI stopped at the usage cap (#22487)
## Rationale

When a workspace hits its AI usage cap, members **without** the
`BILLING` permission flag get zero explanation:
`AIChatNoMoreBillingCreditsBanner` returns `null` for them, and
`AiChatErrorRenderer` also returns `null` for
`BILLING_CREDITS_EXHAUSTED` (deliberately delegating to that same
banner). Net effect — for most seats in a workspace, AI chat just
silently stops working. Sentry shows the cap is hit constantly: 290
users / 90 days on `Billing Credits Exhausted`.

## Why this is the root cause, not a symptom patch

The permission gate exists to hide *billing actions* (upgrade/subscribe
modals) from members who can't act on them — but it was written as "hide
everything", conflating the action with the information. The fix keeps
the gate exactly where it belongs (no upgrade button, no modals for
non-billing members) and renders the information-only banner: "Your
workspace hit its AI usage limit. Ask an admin to upgrade the plan."
Fixing it in the error renderer instead would be the wrong altitude: the
banner mounts *before* a send is attempted (gated on
`hasReachedCurrentBillingPeriodCap`), so members are informed
proactively rather than after a failed send.

## User impact

Non-admin members — the majority of seats — stop experiencing "AI is
broken" and instead see what happened and who can fix it.

## Test plan

- [ ] CI green
- [ ] Manual: member without billing permission at cap → informational
banner, no upgrade button; admin → unchanged upgrade flow

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22487?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-02 21:14:46 +02:00
Félix Malfait a75414a05e fix(sse): treat expected event-stream coordination errors as 403s and never crash the sync loop (#22475)
## Rationale

`NOT_AUTHORIZED` and `EVENT_STREAM_ALREADY_EXISTS` on the event-stream
mutations are **expected coordination outcomes** — the frontend
explicitly recognizes both (`isGracefullyHandledEventStreamError`) and
recovers by recreating its stream. But `EventStreamExceptionFilter`
rethrows them as `InternalServerError`, which (a) Sentry captures on
every single occurrence, and (b) counts as a 500 in operation metrics.

**Production evidence (Sentry):** this turned a March client-regression
into a 119,510-event / 2,607-user flood
([TWENTY-SERVER-FP3](https://twenty-v7.sentry.io/issues/TWENTY-SERVER-FP3),
plus FP0 at ~29k) that buried real errors. The trigger was fixed back
then, but the amplifier — error-level capture of an expected signal — is
still in place, and a residual trickle still fires today.

Second defect, client side: for any *non-graceful* server error (e.g. a
lock-acquisition timeout), `SSEQuerySubscribeEffect.handleError`
**threw** from inside a debounced callback — an unhandled rejection
([TWENTY-FRONT-62M](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-62M),
233 users; [6MM](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-6MM),
65 users) that left the tab's query listeners permanently out of sync
with the server (no more live updates until reload).

## Why this is the root cause, not a symptom patch

The protocol design already says these are recoverable
client-coordination signals — the bug is purely that the server encodes
them with 500 semantics and the client punishes unexpected errors by
giving up instead of resetting. This PR aligns both ends with the
existing design rather than adding new machinery:

- Server: `ForbiddenError` (403) with the same `subCode` — the client's
graceful check already accepts `code === 'FORBIDDEN'`, so this is
compatible by construction; `FORBIDDEN` is already in
`graphQLErrorCodesToFilter`, so monitoring capture stops with no new
filtering logic.
- Client: the non-graceful path now does exactly what the graceful path
does (reset listeners + recreate stream) and *additionally* reports the
unexpected error — visibility without a crash.

## User impact

Tabs that hit any event-stream error now always self-heal back to live
updates instead of silently going stale until reload (~300 users hit the
crash path over 90d). On the ops side: expected coordination noise
leaves error monitoring, and 403/500 metrics become truthful.

## Test plan

- [x] Behavior preserved for graceful codes (same reset path, client
check already includes FORBIDDEN)
- [ ] CI green

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22475?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-02 20:59:42 +02:00
Félix Malfait a445974cff fix(ai): offer Retry when the failed turn persisted partial assistant output (#22478)
## Rationale

When a turn fails mid-stream *after* emitting some text, the failed
turn's partial assistant message is persisted — so the last message in
the thread is the assistant's, and `AiChatErrorUnderMessageList` (which
owns the Retry button, gated on the last message being the user's) never
renders. The error surfaces through `AiChatMessage` →
`AiChatErrorRenderer` instead, and that path never passed `onRetry`.
Result: an error banner with no action for the most common failure shape
(mid-stream provider errors), most visibly after a reload.

## Why this is the root cause, not a symptom patch

This is a wiring omission, not a designed gate. `AiChatErrorRenderer`
already accepts `onRetry`, and the server's `retryLastFailedTurn`
already deletes the failed turn's assistant messages before re-streaming
— the entire retry path for partial-output turns exists and works; only
the prop was never threaded. Verified there's no hidden protective
reason: retrying with partial output cannot duplicate content, because
regeneration is delete-then-restream by design.

## User impact

A mid-stream failure currently strands the user: their only options are
re-typing the message or reloading. With this, the same Retry affordance
appears whether the turn died before or after the first token (Sentry
shows 126 users/30d hitting zero-output failures alone — the with-output
shape shares the same recovery need).

## Test plan

- [x] `AiChatErrorRenderer` retry behavior already covered by existing
rendering; change is prop threading only (~10 lines)
- [ ] CI green
- [ ] Manual: fail a turn mid-stream (kill provider), observe Retry on
the in-message error banner

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22478?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-02 20:57:40 +02:00
neo773 6f64be5751 Gate and meter email group: enterprise license (self-host) + credits (cloud) (#22390)
Email group (marketing email) was gated only by the
`IS_EMAIL_GROUP_ENABLED` feature flag with no server-side enforcement.
This adds real gating, split by deployment:

- **Self-hosted** (`IS_BILLING_ENABLED=false`): requires a valid
Enterprise plan.
- **Cloud** (billing enabled): metered by credits, mirroring the
existing AI credit system. Priced on AWS SES cost ($0.10/1,000 outbound)
× 3 margin = $0.30/1,000 (300 micro-credits/email). Pre-flight blocks
sends when out of credits; each email is charged after SES accepts it,
in the async send job — matching how AWS bills us (no refund on bounce).

Enforcement is applied at every email group resolver, and denials
surface as proper client errors through a dedicated GraphQL exception
filter.
2026-07-02 15:14:49 +00:00
Félix Malfait a28887bba6 feat(server): workspace opt-out of root-domain directory listing (#22423)
## What

Lets a workspace opt out of being surfaced in the multi-workspace
root-domain (app.twenty.com) picker via **email-domain discovery**.

Adds `isDirectoryListingEnabled` (default `true`) on the workspace. When
`false`, the workspace is filtered out of the approved-access-domain
branch of `findAvailableWorkspacesByEmail`, so a user whose email domain
matches an approved access domain no longer sees the workspace in the
sign-up picker.

## Scope of the opt-out (deliberately narrow)

The filter is applied **only** to the approved-access-domain discovery
source:

- **Members** (`availableWorkspacesForSignIn`) — never filtered; they
keep access.
- **Explicit invitations** — never filtered; the intent is one-to-one.
- **Approved-access-domain discovery** — the only "listing" source,
gated by the flag.

A hidden workspace stays fully reachable by members and invited users
via the direct workspace subdomain; it just isn't advertised in the
global picker.

> Open question for review: do we also want a stronger mode that hides
the workspace from the root-domain picker even for existing members
(forcing them to use the subdomain directly)? That would additionally
filter the member/invitation sources and is a larger behavior change —
not included here.

## Changes

**Backend**
- `workspace.entity.ts` — new `isDirectoryListingEnabled` column
(`@Field`, default `true`).
- `user-workspace.service.ts` — filter the approved-access-domain branch
on the flag.
- `update-workspace-input.ts` — expose the field on `updateWorkspace`.
- `workspace.service.ts` — `PermissionFlagType.SECURITY` (same as the
other discovery/security toggles).
- Fast instance command adding the column (default `true`, so no
existing workspace is hidden).

**Frontend**
- Settings > Security: a **"List in workspace directory"** toggle (shown
only in multi-workspace mode) that flips the flag via `updateWorkspace`,
mirroring the existing `isInternalMessagesImportEnabled` toggle.
- Threaded the field through the current-user fragment,
`CurrentWorkspace` type, and mock data.
- Regenerated the metadata + client-sdk GraphQL types
(`generated-metadata`, `twenty-client-sdk/.../generated`) — generated
against a server booted from this branch.

## Verification

- `tsgo` typecheck: 0 errors. `oxlint`: 0/0. `oxfmt`: clean.
- Codegen diff verified to contain **only** the new field (no unrelated
drift).
- Tests not run locally; CI covers unit/integration + the
codegen/migration freshness checks.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-07-02 16:55:40 +02:00
martmull 4f44131901 perf(front): query only rendered fields in application settings pages (#22454)
Part of the application settings architecture work:
https://github.com/twentyhq/core-team-issues/issues/2456

Application settings pages pulled far more data than they render:

- **`FindOneApplicationByUniversalIdentifier`** fetched the full
`ApplicationFields` fragment (all nested agents, objects,
logicFunctions, frontComponents, commandMenuItems) just so
`SettingsAvailableApplicationDetails` could check whether an app is
installed. Slimmed to `id, universalIdentifier, name, version` (its only
caller; every field usage audited).
- **`FindManyApplicationRegistrations`** (developer tab list) fetched
the 17-field registration fragment including `isConfigured`, which
triggers a per-row DataLoader resolve. The list renders only `id, name,
universalIdentifier, sourceType` — new lean
`ApplicationRegistrationListItem` fragment. The detail page and admin
list, which actually render `isConfigured`, keep the full fragment.
- **`SidePanelEditOwnerSection`** pulled the full `FindOneApplication`
payload to render a name — new `FindOneApplicationName` (`id, name`)
query.

Regenerated `generated-metadata/graphql.ts` (document-level changes
only, zero schema drift; data/admin outputs byte-identical).
Deliberately untouched: the installed-app detail page query (renders its
nested collections across tabs), the sub-detail pages that share its
cache entry, and the marketplace manifest usage (needs backend fields —
later PR).

Verified: typecheck, oxlint/oxfmt on touched files, jest (applications
19/19, navigation-menu-item 86/86).

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

https://claude.ai/code/session_011sST4rPLU1Koi2oVGi84ei

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22454?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-02 16:52:28 +02:00
Félix Malfait 4aaf171d63 feat(ai): add ask_questions interactive clarifying-question tool (#22346)
## What & why

Adds an `ask_questions` tool that lets the in-app **Ask AI** assistant
**pause a turn to ask the user one or more multiple-choice questions**
(per the [Figma
design](https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=105959-117153))
and resume once answered — instead of guessing on
ambiguous/consequential decisions.

The tool is **harness-only**: an interactive question UI is meaningless
without a user to answer it, so it must be absent from MCP and from
head-less workflow agents.

## Design — true tool-result resume (not a synthetic user message)

The user's answer is a **structured tool result bound to the
`toolCallId`**, and the **same agent turn resumes** — exactly how
Anthropic (`tool_result` by `tool_use_id`) and OpenAI
(`function_call_output`) model human-in-the-loop.

The naive form of this (leave the tool call in `input-available` to mean
"pending") is **impossible** here: `finalizeDanglingToolParts` rewrites
`input-available` → `output-error` ("Tool execution was interrupted") on
both the persist path (`addMessage`) and the model-reload path
(`chat-execution.service.ts`). That util is a load-bearing safety net,
so weakening it is the wrong move.

Instead:

- `ask_questions` is an **inline, chat-only tool with an `execute` that
returns a `status: 'pending'` result immediately**, so the tool part is
always `output-available` and **immune to `finalizeDanglingToolParts`**.
`stopWhen(hasToolCall('ask_questions'))` halts the turn right after the
call (the model never sees the placeholder).
- A nullable **`thread.pendingQuestionMessageId`** marker records that a
turn is awaiting an answer.
- The new **`answerAgentChatQuestion`** mutation atomically *claims* the
question (clears the marker, marks the thread streaming), **writes the
answer onto the same tool part** (`status: 'answered'`), and
**re-enqueues the turn via the existing `existingTurnId` plumbing**
(`isResume` bypasses the per-turn dedup guard). On resume
`finalizeDanglingToolParts` leaves the `output-available` part untouched
and `convertToModelMessages` emits `assistant(tool_use)` +
`tool_result(answers)`, so the model continues.

This achieves the platform-aligned semantics **without** weakening the
finalize safety net or inventing a fragile new part state.

### Meets the two requirements

- **Survives refresh, scoped per-thread** — the pending state is a
normal persisted `output-available` part + the thread marker; the
frontend card is derived per-thread from the loaded messages, so it
re-appears on reload and only on its own thread.
- **Takes priority over the queue** — a unified `isBlocked =
activeStreamId || pendingQuestionMessageId` gate is applied in both
`sendChatMessage` (new messages queue) and `flushNextQueuedMessage` (the
drain). The queue cannot unpile until the question is answered and the
resumed turn completes.

### Harness-only by construction

`ask_questions` is added **only** to the chat's inline `activeTools`
(like `learn_tools`/`execute_tool`/`load_skills`). It never enters the
tool registry/catalog, so it is invisible to MCP and to workflow agents
— no `MCP_EXCLUDED_TOOL_NAMES` entry needed.

## UX

While a question is pending, the **composer is replaced by the question
card** (matching the Figma): question title + pager (`1/2`), numbered
option rows (`IconSquareNumber*`) with per-option info-icon descriptions
and a "Recommended" badge, and the normal composer as the free-text
fallback ("Type anything to do differently."). The transcript shows a
compact "Asking questions…" status line that becomes an answered
summary.

## Changes

**twenty-shared**
- `ai/types/AskQuestionsToolTypes.ts` —
`AskQuestionItem/Option/Answer/Result`, `ASK_QUESTIONS_TOOL_NAME`.

**twenty-server**
- `ai-chat/tools/ask-questions.tool.ts` — inline tool factory
(pending-result `execute`, zod schema, 1–4 questions × 2–4 options).
- `chat-execution.service.ts` — add to `activeTools` +
`preloadedToolNames`; `hasToolCall` in `stopWhen`.
- `chat-system-prompts.const.ts` — when-to-use guidance.
- `entities/agent-chat-thread.entity.ts` — `pendingQuestionMessageId`
column.
- `stream-agent-chat.job.ts` — set the marker on a question pause;
bypass the dedup guard on resume; suppress the no-text warning for
question pauses.
- `agent-chat-streaming.service.ts` — gate `flushNextQueuedMessage`;
`enqueueResumeStream`.
- `agent-chat.resolver.ts` — gate `sendChatMessage`;
`answerAgentChatQuestion` mutation.
- `agent-chat.service.ts` — `resolvePendingQuestion` (atomic claim +
write answer).
- `dtos/agent-chat-question-answer.input.ts`, `ai.exception.ts`
(`QUESTION_NOT_PENDING`), `utils/find-pending-question-part.util.ts`.

**twenty-front**
- `components/AiChatQuestionCard.tsx` — the interactive card (matches
Figma tokens) + `__stories__/AiChatQuestionCard.stories.tsx`.
- `components/AiChatEditorSection.tsx` — swap the composer for the card
while pending.
- `components/AiChatQuestionStatusRenderer.tsx` + branch in
`AiChatAssistantMessageRenderer.tsx`.
- `states/selectors/agentChatPendingQuestionComponentSelector.ts`,
`types/AgentChatPendingQuestion.ts`.
- `hooks/useSubmitQuestionAnswer.ts` + `utils/markQuestionAnswered.ts`
(optimistic) + `graphql/mutations/answerAgentChatQuestion.ts`.

A design doc lives at
`packages/twenty-server/docs/ASK_USER_QUESTION_TOOL_PLAN.md`.

## Migration

Adds a nullable `pendingQuestionMessageId` (uuid) column to
`core.agentChatThread`. Needs a generated **fast instance command**
(`database:migrate:generate --name addThreadPendingQuestion --type
fast`) — see "Verification status".

## Tests

- Server: `ask-questions.tool.spec.ts` (pending echo + schema bounds),
`find-pending-question-part.util.spec.ts`.
- Front: `markQuestionAnswered.test.ts`, plus the Storybook story.

## Verification status (please read)

This branch was authored in an environment where the monorepo `yarn
install` repeatedly failed on transient TLS resets from the package
registry, so I could **not** locally run the mechanical gates. The logic
was reviewed by hand and the `ai@6.0.97` exports used (`hasToolCall`,
`stepCountIs`, `generateId`) were confirmed against the package's type
defs. Still **TODO** (will rely on CI / a follow-up once deps install):

- [ ] `nx run twenty-shared:generateBarrels` (the `ai/index.ts` export
was added by hand; regen to reconcile)
- [ ] `nx run twenty-front:graphql:generate` (new mutation + input type)
- [ ] generate the fast instance command (migration) for the new column
- [ ] `typecheck` + `lint:diff-with-main` (front + server) — expect
minor import-ordering autofixes
- [ ] run the unit tests

**Screenshots:** reproducing the live flow needs an AI provider API key
(to get the model to actually call `ask_questions`), which isn't
available here. The card can be screenshotted from its **Storybook
story** (`AiChatQuestionCard.stories.tsx`) with no API key — I'll add
that image once deps install, or a reviewer can run `nx storybook
twenty-front`.

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

https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22346?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-02 15:32:18 +02:00
Félix Malfait d709467902 feat(ai): surface AI chat stream failures through one typed error channel (#22434)
## Context

Investigating a report where the AI chat showed only a `...` spinner
while the network response clearly contained `No AI models are
available`. Root cause: terminal stream failures reach the client on
**two mismatched channels**.

| Representation | Persisted (survives reload) | Rendered by client |
|---|---|---|
| AI-SDK `error` chunk (inside `stream-chunk`) |  RPUSH'd to Redis | 
dropped by `readUIMessageStream` (no message part, no error state) |
| typed `stream-error` event |  never persisted |  sets the error atom
|

Live, the `stream-error` event renders. But on reload,
`chatStreamCatchupChunks` replays only the persisted **error chunk** —
which the reducer discards — and the streaming indicator never clears.

## Change

Collapse to a single typed error contract:

- **Suppress the opaque `error` chunk** in the stream job; every failure
is surfaced through the typed `stream-error` event. Errors are mapped
via `mapErrorToStreamError` so an `AiException` keeps its
`AiExceptionCode` (e.g. `API_KEY_NOT_CONFIGURED` → the existing "AI not
configured" banner) instead of leaking a raw string.
- **Persist the terminal error** next to the accumulated chunks and
expose it as an explicit `error { code message }` field on
`ChatStreamCatchupChunks`, so a client catching up after a reload
recovers it — no dependency on the AI SDK's internal chunk shape.
- **Reset per-thread stream state at job start**, so a failed turn's
leftover chunks/error never replay on the next stream.
- **Client replays the catchup error** as a terminal `stream-error`
event, which clears the streaming indicator and renders the error (fixes
the infinite spinner on a stream that ended in error).

## Notes

- `ChatStreamError` is a new metadata GraphQL type; generated types
(twenty-front metadata + client-sdk) were hand-updated to keep the tree
consistent and will be reconciled by CI's `graphql:generate` check if
anything differs.
- Server unit test added for the error mapping. No schema/DB migration.

## Test plan

- [ ] With no AI provider configured, send a chat message → error
renders immediately (not a spinner).
- [ ] Reload the thread → the error still renders (recovered from
catchup), indicator not spinning.
- [ ] Configure a provider and send again → normal streaming; no stale
error from the previous failed turn.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22434?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-02 14:50:57 +02:00
Weiko 632114e5e2 fix(front): hide sub-item tree connector in navigation drag preview (#22442)
## Context

Dragging a navigation menu sub-item cloned the whole row as the floating
drag preview, which included the vertical tree-connector bar on the
left. Hide that connector inside the moving clone (marked by dnd-kit
with [data-dnd-dragging]) so the preview shows only the icon and label.
The static placeholder left in the list and the other items keep their
connectors, so the list layout is unchanged.

## Before



https://github.com/user-attachments/assets/8fc04a28-e1d2-49e0-88c1-ef03f89475c2


## After


https://github.com/user-attachments/assets/dbcd3cc5-8a51-40a3-98de-a8ea3d440774
2026-07-02 14:21:08 +02:00