f90137b536ad5165b78facd953ca024ea8858ffe
6223 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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 |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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> |
||
|
|
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">``<img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</a> --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
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" /> |
||
|
|
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">``<img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</a> |
||
|
|
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 | | --- | --- | |  | <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> |
||
|
|
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>
|
||
|
|
e609320666 | Squirclesssss 🟦🔵 (#22535) | ||
|
|
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> |
||
|
|
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. -->
|
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. -->
|
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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" />  ## 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. --> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. -->
|
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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 |