712e5ece7e7b69ec885c0fb8f3f1333dca23c4db
5297 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
712e5ece7e |
Strip template elements from the HTML block preview (#23866)
Follow-up to a code scanner alert on `HtmlNodeView`. The alert itself is
not a live vulnerability, but auditing the hand-rolled sanitizer behind
it turned up one gap worth closing.
## What I checked
`sanitizeHtmlPreview` is a blocklist sanitizer feeding
`dangerouslySetInnerHTML` in the editor, so I ran ~30 payloads through
the real implementation in headless Chromium, inserting the sanitized
output into a live document. A deliberately unsanitized control payload
fired, so the harness was actually detecting execution.
Nothing executed. The parser-differential classes it already survives:
`noscript`, `noembed`, `noframes`, `xmp`, `listing`, `title` and
`textarea` raw-text handling; `svg`/`math`/`mglyph`/`foreignObject`
namespace confusion; comment breakouts; table foster parenting; `<body
onload>`; `<iframe srcdoc>`; `data:text/html`; and `javascript:` with
entity and control-character obfuscation.
## The gap
`document.body.querySelectorAll('*')` does not descend into a
`<template>`'s content, which lives in a separate document fragment. So
this:
```html
<template>``<img src=x onerror="alert(1)">``</template>
```
came back out of the sanitizer with its handler intact.
It does not execute as currently used — template content is inert when
assigned through `innerHTML`, and I confirmed that. But a live event
handler sitting inside a string the sanitizer just declared clean is a
footgun for anything that later clones, re-parses or forwards it.
The outbound email sanitizer already drops template content, so blocking
the element here also stops the preview from showing something the sent
email would not contain.
## Scope
One word in the blocklist plus tests. Inline styles are deliberately
untouched, since dropping them would make the preview diverge from the
rendered email.
An earlier revision also added `contain: paint` to the preview, to stop
a block from painting over the editor chrome. That was dropped after
testing showed it clips box-shadows bleeding past the block edge and
negative-margin full-bleed layouts, both normal in email design. Details
in the review thread. The overlay it guarded against needs an
authenticated member who can already edit the template, which does not
justify constraining newsletter design.
## Testing
Re-ran the full payload corpus with the new blocklist. The template
payload's handler is gone, every other result is byte-identical, and the
control still fires. The two new unit tests were verified to pass under
jsdom, the environment jest actually uses.
|
||
|
|
8774bf8604 |
Self-host every font instead of loading them from Google (#23859)
Google Fonts logs the IP and user agent of everyone who loads a font from it. Any page of ours that links to `fonts.googleapis.com` hands our users (and every self-hoster's users) to a third party for nothing in return, since we can serve the same bytes ourselves. After this PR there is no reference to `fonts.googleapis.com`, `fonts.gstatic.com` or `next/font/google` left in the repo. ## What changed **twenty-front, PDF export.** `exportBlockNoteEditorToPdf` registered Inter by URL against `fonts.gstatic.com`, so exporting a note made the browser fetch three TTFs from Google. The registration turned out to be unnecessary altogether: `@blocknote/xl-pdf-exporter` already registers an `Inter` family for its PDF schema, shipped inlined in the package as a base64 TTF with the same 2849-codepoint coverage. Deleting our `Font.register` means no font request leaves the browser, with 41 fewer lines and nothing vendored. Only weights 400 and 700 were ever used, and 700 already resolved to blocknote's `Inter18pt-Bold` before this branch, so the custom 500/600 registrations were dead. The only rendering change is body text going from `Inter` to `Inter18pt`, the same typeface at its 18pt optical size. **twenty-sdk, OAuth callback page.** The local "you can close this tab" page linked to Google Fonts, which meant running `twenty auth` phoned Google from the developer's browser. Replaced with a system font stack; a transient callback page did not justify a webfont round trip in the first place. **twenty-ui, Storybook.** `preview-head.html` loaded Inter from Google. It now imports `@fontsource/inter` in `preview.tsx`, matching what twenty-front's Storybook already does. **twenty-website.** Host Grotesk, Aleo, Azeret Mono and VT323 came through `next/font/google`. Next self-hosts those at runtime, so this was not a visitor-facing leak, but the build still had to reach Google, which makes builds non-hermetic and fails in an air-gapped environment. The latin subsets are now vendored in `src/fonts/`, next to the Inter files that were already there, and loaded with `next/font/local`. All four are OFL 1.1; `src/fonts/README.md` records each file's upstream and license. Total added weight is ~78 KB, and these are the exact files Next was downloading at build time anyway. Host Grotesk and Azeret Mono ship as single variable files, so they are declared once over their full `wght` axis rather than as one face per weight. ## Also removed Both Storybooks pulled `iframeResizer.contentWindow.min.js` from `cdnjs.cloudflare.com`. Storybook has not needed it since v7 and nothing in either package references `iframeResizer` or `parentIFrame`, so it was a third-party script executing in the preview iframe for no reason. Argos does not screenshot through the manager iframe either: `@argos-ci/storybook` hooks Vitest browser mode and calls `server.commands.argosScreenshot`, so Playwright drives the page directly. ## Verification Not just typecheck. The interesting parts were tested end to end, which caught two bugs an earlier revision of this PR had introduced. **PDF export** — production Vite build, served over HTTP, real Chromium, exporting through the actual `exportBlockNoteEditorToPdf`, then extracting the PDF's text back out: ``` Latin heading Cyrillic: Привет мир Greek: Ελληνικά κείμενο Latin-ext: Zażółć gęślą jaźń, Český Vietnamese: Tiếng Việt PASS Latin / Cyrillic / Greek / Polish / Czech / Vietnamese ``` Embedded fonts are `Inter18pt-Regular` / `Inter18pt-Bold`, no Helvetica fallback, zero requests off-origin. **Website** — built it, audited the build output (12 `@font-face` rules, all `/_next/static/media/`, weights `300 800` / `100 900` / `300` / `400` / `400,500,600`, `display: swap` preserved), then loaded it in Chromium: 136 requests, zero to Google. The deployed preview was checked too: no Google references in the served HTML or across all 21 CSS chunks, every font file returns `200 font/woff2` and parses to the expected family, and the asset hashes match a local build byte for byte. **Two bugs this caught**, both in earlier commits on this branch, both now fixed: 1. Registering `@fontsource/inter`'s latin file dropped coverage from 2849 codepoints to 230, silently removing Cyrillic, Greek, Vietnamese and extended-Latin from every export. fontsource splits Inter into seven per-script files chosen by `unicode-range`, but `Font.register` binds one file per weight with no equivalent. 2. Any woff2 aborts the export outright with `RangeError: Offset is outside the bounds of the DataView`. fontkit parses woff2, but `@react-pdf`'s subsetter chokes on the transformed `glyf` table. Confirmed format was the only variable by running identical content through local TTF, WOFF and WOFF2 files. Both are moot now that the registration is gone, but they are why this is worth a careful look rather than a rubber stamp. ## Left alone, but worth knowing about More third-party calls exist. None are font-related and each is a separate decision: - `twenty-website` loads `dotlottie-player.wasm` from **unpkg.com** at runtime on the homepage, via `@lottiefiles/dotlottie-react`. This is a live third-party CDN request on every visit, the same class of problem as the fonts, and looks like a small config change to self-host. - The halftone studio loads the Draco decoder from `www.gstatic.com` and, in exported scenes, three.js from `unpkg.com`. - The partners marketplace fixtures hotlink logos from `cdn.simpleicons.org` and `upload.wikimedia.org`. - reCAPTCHA and the Front support chat are config-gated and off unless an admin configures them, which seems right. - `APP_REGISTRY_CDN_URL` defaults to `https://unpkg.com`. - `twenty-front/index.html` points its `og:image` at `raw.githubusercontent.com`. Only social crawlers fetch it, so this is cosmetic. |
||
|
|
3cbcf999d6 |
Add call recording transcript and summary widget types (#23864)
Registers two record-page widget types for call recordings: CALL_RECORDING_SUMMARY and CALL_RECORDING_TRANSCRIPT. - Widget type and configuration type enums, configuration DTOs, and creation/update validators - Core pageLayoutWidget_type_enum migration (2.29 fast instance command) - Shared configuration types and regenerated metadata client schemas Widget components and their placement on the callRecording record page layout come in a follow-up PR. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23864?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. --> |
||
|
|
c7cfd143c7 |
fix(twenty-front): keep auto-select model preselection instead of discarding it (#23854)
## Context Opening the Ask AI panel with a FAST model preselection (`useOpenAskAiPageWithPreprompt`) sets the chat's model to the workspace's `fastModel`. For every workspace on default settings that value is the auto-select sentinel `default-fast-model`. ## Bug `useAgentChatModelId` validates the selected model against the enabled-models list — and `useWorkspaceAiModelAvailability` deliberately filters sentinel ids out of that list. The preselection is therefore silently discarded (`selectedModelId = null`), the request is sent with no model, and the server falls back to its default — the **smart** model. Net effect: FAST preselection no-ops on default-configured workspaces (observed on twenty-internal: a `model: 'FAST'` entry point ran on gpt-5.6-sol instead of gpt-5.6-luna). ## Fix Treat auto-select sentinel ids as always available in the check — the server-side registry already resolves them (`getEffectiveModelConfig` → `getDefaultSpeedModel`). One line + a regression test. ## Test `useAgentChatModelId.test.tsx`: new case asserting a sentinel selection survives to `modelIdForRequest`; all 4 pass. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23854?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. --> |
||
|
|
b2684ce107 |
Clear to-one relation key instead of storing {id: null} in record workflow steps (#23869)
Deselecting a to-one relation in a Create Record / Upsert Record step
stored `{ "id": null }` instead of removing the field from the step
input. The step form then renders the field as empty, so nothing signals
that a value is still there, and the run fails later with:
```
Relation "idOpportunity" requires connect or disconnect operation
```
### Why
The relation picker fires `onChange(null)` when the current selection is
dropped (`FormSingleRecordPicker`, "No record" entry).
`handleFieldChange` wraps every to-one relation value as `{ id: value
}`, so `null` became `{ id: null }` and got persisted in `objectRecord`.
At runtime that shape is not a legacy `{ id: "<uuid>" }`, so it is left
untouched by `formatWorkflowRecordRelationFields` and reaches the common
API data arg processor, which rejects any relation value that is not a
`connect`/`disconnect` operation.
The field also reads as empty afterwards (`formData[field]?.id` is
`null`), so the poisoned state is indistinguishable from a clean one in
the UI, and the ✕ that would have cleared it properly is not rendered.
### Fix
Treat a cleared to-one relation as a field removal in both record forms,
matching what the chip's ✕ (`handleFieldClear`) already does. The logic
lives in `buildUpdatedRecordActionFormData`, shared by both components
along with the `RecordActionFormData` / `RelationManyToOneField` types
they each declared separately.
Update Record is unaffected: it stores relations under the join column
(`pointOfContactId`) with a raw value, where `null` is a valid
disconnect.
### Test
Manually, on a Companies Create Record and Create or Update Record step,
using the `Account Owner` relation: pick a record, then pick "No record"
in the same dropdown, and read the persisted step from
`workflowVersion.steps`.
| | `objectRecord` after "No record" |
|---|---|
| before | `{"accountOwner": {"id": null}}` |
| after | `{}` |
Selecting a record still stores `{"accountOwner": {"id": "<uuid>"}}`.
|
||
|
|
f4d5500fc4 |
fix(front): run a single Monaco instance across the app (#23855)
## Problem Three Sentry issues, all `Missing requestHandler or method: <method>`, first seen in v2.27.0: | Method | Page | Events | |---|---|---| | `findDocumentColors` | `/settings/mcp-apis` | 170 | | `resetSchema` | `/settings/mcp-apis` | 22 | | `getCodeFixesAtPosition` | `/object/workflow/…` | 4 | The first two are Monaco **JSON worker** methods, the third a **TypeScript worker** method. All of them bottom out in `monaco-editor/esm/vs/editor/common/services/editorSimpleWorker.js` at `$fmr` — the foreign-module dispatcher — with `_foreignModule` still `null`. ## Root cause Two Monaco copies end up on the page at **different versions**: 1. **ESM `monaco-editor@0.52.2`**, bundled by Vite — what GraphiQL 5 uses. 2. **AMD `monaco-editor@0.55.1` from jsDelivr** — `@monaco-editor/react` → `@monaco-editor/loader@1.7.0`, whose default CDN path is hardcoded to `monaco-editor@0.55.1/min/vs`. Nothing calls `loader.config({ monaco })`, so it goes to the CDN. `setupGraphiqlMonacoWorkers.ts` assigns **`globalThis.MonacoEnvironment`**, a single global both instances read, as a module side effect of the lazily-routed `GraphQLPlayground`. So once the playground has been opened, the 0.55.1 CDN instance stops using its own AMD workers and starts getting Vite-bundled 0.52.2 ones. The two versions don't share a worker protocol: 0.52 routes language-service calls through `$loadForeignModule` + `$fmr`, which 0.55's client never sends. `_foreignModule` stays `null`, and every call rejects. On `/settings/mcp-apis` the consumer is `SettingsMcpSetup.tsx` — `<CodeEditor language="json">` for the MCP config. Monaco fires `resetSchema` on `onWillDisposeModel` / `onDidChangeModelLanguage` and `findDocumentColors` continuously, which is why one bug produces 170 events and 22. There is a second, independent bug in the same file: the `switch` only maps `json` and `graphql`, so `typescript` / `javascript` / `css` / `html` fall through to the bare `EditorWorker`, which carries no language service at all. That's the workflow-page `getCodeFixesAtPosition`, and it would break even with matching versions. Impact is worse than the log noise suggests: after visiting the playground, JSON validation/colors in the MCP config editor and TS intellisense/quick-fixes in the workflow code editor silently stop working for the rest of the session. ## Changes - **`twenty-ui/src/input/CodeEditor/CodeEditor.tsx`** — configure `@monaco-editor/react` with the bundled Monaco (`loader.config({ monaco })`) instead of letting it fetch its own from jsDelivr. The import stays dynamic so Monaco is still only downloaded when an editor actually renders; the component shows its existing `Loader` until the loader is configured. - **`twenty-front/src/modules/app/utils/setupMonacoEnvironment.ts`** (new, replaces `settings/mcp-and-apis/utils/setupGraphiqlMonacoWorkers.ts`) — app-level worker factory mapping every label Monaco can ask for: `json`, `css`/`scss`/`less`, `html`/`handlebars`/`razor`, `typescript`/`javascript`, `graphql`, and the generic editor worker as the fallback. - **`twenty-front/src/index.tsx`** and **`.storybook/preview.tsx`** — set it up once for the app and for stories, rather than as a side effect of one lazy route. Dropping the CDN also means the code editors work in self-hosted and air-gapped deployments, which today silently fall back to a broken editor when jsDelivr is unreachable. ## Verification - `nx build twenty-front` passes; `css.worker`, `html.worker` and `ts.worker` chunks are now emitted alongside the existing `editor`/`json`/`graphql` ones. - Monaco stays lazy — `edcore.main` is not statically imported by the entry chunk and is absent from `index.html`'s modulepreloads. Measured against a baseline build of `main`, the entry chunk goes from 2,598,088 B to 2,599,040 B (**+952 B**). - `oxlint` and `oxfmt --check` clean on all touched files; `tsc --noEmit` clean for `twenty-ui` and reports nothing new for the touched `twenty-front` files. Not verified in a browser — worth a manual pass on the playground → MCP tab → workflow code editor sequence that reproduced the original errors. Fixes TWENTY-FRONT-8YV Fixes TWENTY-FRONT-8YW Fixes TWENTY-FRONT-ADE --- _Generated by [Claude Code](https://claude.ai/code/session_01RgPXkmUHANwD7ooUMqNYr9)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23855?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. --> |
||
|
|
829ef9d8b9 |
Revert AI chat chips to the [[kind:...:label]] syntax (#23852)
Removes the `[[kind:...:label[[/kind]]` closing-tag syntax and goes back
to the simpler `[[kind:...:label]]` form for all four chip kinds
(record, object, field, view).
The parser is now a single regex pass instead of a two-pass scan with a
per-reference closing-tag search, a legacy fallback and surplus-bracket
handling. That removes 11 files. The label pattern excludes `[`, `]` and
newlines, which is what keeps an unclosed marker from swallowing the
text (and the marker) that follows it.
```mermaid
flowchart LR
subgraph before ["Before — two passes"]
O1["scan for marker openings"] --> O2["window each opening<br/>up to the next one"]
O2 --> O3["find that kind's closing tag<br/>inside the window"]
O3 --> O4["record only:<br/>bare-terminator fallback"]
O4 --> O5["consume surplus<br/>closing brackets"]
end
subgraph after ["After — one pass"]
N1["matchAll, one regex:<br/>object · field · view · record"] --> N2["map each match<br/>to a chip"]
end
before -.->|"11 files deleted"| after
```
Two things to know:
- Messages already stored with closing tags render as raw text instead
of chips.
- Malformed model output is no longer compensated for: a surplus `]`
after a chip stays in the text, and a display name containing brackets
does not chip. The system prompt tells the model to avoid both.
Rendering cost is unchanged for normal messages and noticeably lower on
long bracket runs, since the old opening pattern had to scan them.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23852?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. -->
|
||
|
|
d8b494d530 |
Make subdomain minimum length configurable via env var (#23209)
## What Introduces a `SUBDOMAIN_MIN_LENGTH` environment variable (default `3`) controlling the minimum number of characters allowed for a workspace subdomain. Until now the minimum was hardcoded (`3`), baked into the shared `SUBDOMAIN_PATTERN` regex. ## How - Added the `SUBDOMAIN_MIN_LENGTH` config variable (default `3`) in `config-variables.ts`. - Relaxed `SUBDOMAIN_PATTERN` in `twenty-shared` to validate format and max length only, so the minimum length policy now lives with the caller instead of being embedded in the regex. - `isSubdomainValid` now takes a `minLength` argument (defaulting to `3`) and enforces it explicitly. - `SubdomainManagerService` reads `SUBDOMAIN_MIN_LENGTH` from config and passes it to every validation call, making the server the authoritative source. ## Scope Server-side only. The frontend validation schema keeps its default `.min(3)` UX check and is unchanged; the server remains the source of truth for what subdomains are accepted. ## Tests - Updated the shared `isValidTwentySubdomain` tests to reflect that the pattern no longer enforces a minimum length. - Added tests for the configurable minimum in `is-subdomain-valid.util.spec.ts`. - Updated the service spec config mock to return a numeric value for `SUBDOMAIN_MIN_LENGTH`. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23209?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. --> |
||
|
|
5278a47b55 |
refactor(front): move workflow run step logs into workflow-actions (#23841)
Follow-up on unapplied review feedback from #21142. ### Folder structure @thomtrp: > We now have a folder workflow-run/observability, but we have workflow-run related components in workflow-actions folder already. I would avoid that workflow-run/observability folder. The `workflow-run/` folder held nothing but `observability/`, so it is removed entirely and each step log detail component now sits with its action: | File | New location | | --- | --- | | `WorkflowRunStepLogsAiAgentDetail.tsx`, `WorkflowRunStepLogsToolCallRow.tsx` | `workflow-actions/ai-agent-action/components/` | | `WorkflowRunStepLogsCodeDetail.tsx` | `workflow-actions/code-action/components/` | | `WorkflowRunStepLogsHttpRequestDetail.tsx` | `workflow-actions/http-request-action/components/` | | `WorkflowRunStepLogsDetail.tsx`, `WorkflowRunStepLogsEntries.tsx`, `WorkflowRunStepLogsEmailDetail.tsx`, `workflowRunStepLogsStyles.ts` | `workflow-actions/components/` | | `formatDuration.ts`, `formatBytes.ts` | `workflow-actions/utils/` | The email detail stays in the shared `components/` folder since there is no dedicated send-email action folder on the front end (email editing lives at the root as `WorkflowEditActionEmailBase.tsx`). `workflowRunStepLogsStyles.ts` goes next to the shared components rather than `utils/`, since it is styled components and not utils. Also folds in @FelixMalfait's `2 export in 1 file` comment: `workflowRunStepLogsFormatters.ts` is split into `formatDuration.ts` and `formatBytes.ts`. ### AI comments @thomtrp: > same, let's not keep AI comments Removes every comment #21142 introduced, across the front end, server and shared packages: - `WorkflowRunStepLogsEntries.tsx` - the `onlyLatestIteration` prop block - `workflow-run-step-log-schema.ts` - the transport-failure note and the permissive-schema rationale - `strip-ansi-escapes.util.spec.ts`, `build-http-request-step-log.util.spec.ts`, `truncate-string-to-utf8-byte-budget.spec.ts`, `agent-async-executor.service.spec.ts` - the byte-vs-char and pre-fix-behaviour commentary No behaviour change. Locale catalogs are deliberately left untouched; the next i18n run picks up the new source paths. ### Still open from #21142, not covered here - `persistStepLog` try/catch duplicated across the code, tool-backed and ai-agent actions - `draft-email-tool` returning both `sanitizedHtmlBody` and `plainTextBody` - storing both `totalCostInDollars` and `creditsUsedMicro` - the byte-budget truncation utilities being over-engineered - `strip-ansi-escapes` being local to application logs |
||
|
|
1267697b2c |
Fix missing border below the last record table row (#23846)
The bottom border of the last row of a record table is missing, except under the sticky first columns. <img width="600" alt="before" src="https://github.com/user-attachments/assets/placeholder" /> ## Cause Two things combine. **A 1px off-by-one in the virtualization grid.** Virtualized rows are absolutely positioned on a grid whose pitch is `RECORD_TABLE_ROW_HEIGHT + 1` (row plus its bottom border), and `RecordTableRowVirtualizedContainer` reserves the first slot for the header: ```ts const pixelsFromTop = realIndexByVirtualIndex * (RECORD_TABLE_ROW_HEIGHT + 1) + (RECORD_TABLE_ROW_HEIGHT + 1); ``` `RecordTableVirtualizedBodyPlaceholder` reserves `n * (RECORD_TABLE_ROW_HEIGHT + 1)` of in-flow height to match. But header cells are sized `height: RECORD_TABLE_ROW_HEIGHT` with their `border-bottom` inside that box, so the header only occupies 32px, not the 33px the grid assumes. Everything after the placeholder therefore sits one pixel above the grid. **The add-new row started painting over that pixel.** It used to be an unpositioned sibling, so the absolutely positioned rows painted above it (positioned descendants paint after in-flow blocks) and the overlap was invisible. #23211 wrapped it in `DragDropItemEndDropZone`, which is `position: relative`; #23752 kept that as `StyledEndDropZone`. It is now a positioned element later in DOM order, so it paints over the rows and its opaque background covers the last row's border. The border survives only where cells carry their own `z-index` — the sticky first columns. Measured on `/objects/workflows` with 2 records, before the fix: | element | top | bottom | | --- | --- | --- | | header row | 88 | 120 (height 32) | | last row container | 154 | 187 | | add-new wrapper | 186 | 218 | ## Fix Give the header container the full row slot (`RECORD_TABLE_ROW_HEIGHT + 1`) so the body lines up with the grid the virtualization already assumes. Header cells keep their own 32px sizing, so their internal layout is unchanged. This also closes the 1px gap that previously sat between the header and the first row. After the fix, on the same view: | element | top | bottom | | --- | --- | --- | | header row | 88 | 121 (height 33) | | first row | 121 | 154 | | last row | 154 | 187 | | add-new wrapper | 187 | 219 | Overlap 0, header-to-first-row gap 0. Only the ungrouped virtualized table was affected. `RecordTableRecordGroupRows` has the same `position: relative` wrapper, but its rows are in normal flow, so the header change just shifts the whole body down a pixel with no overlap possible. ## Testing Ran the app locally against seeded data: - Workflows (2 rows): border restored across the full width, geometry above verified in the DOM. - Companies (599 rows): header 33px, first row flush at 0, uniform 33px pitch across all 240 mounted row containers; scrolled and confirmed rows slide under the sticky header cleanly. - Verified the diagnosis independently by toggling the end drop zone to `position: static` in the running page, which restores the border the same way. `npx nx typecheck twenty-front` and `npx nx lint:diff-with-main twenty-front` are green. --- _Generated by [Claude Code](https://claude.ai/code/session_01VNEMsZbCA7x55n2trMhE47)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23846?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. --> |
||
|
|
ae235d3b24 |
Scope sessions and application authorizations to the workspace (#23843)
Settings / Profile / Devices listed every live session for the account, so a person who belongs to two workspaces saw all of them from either one, and "Log out all other devices" signed them out everywhere. Almost nothing in Twenty is account-wide, and a session belongs to the workspace its exchange selected, so neither the list nor the revocations are another workspace's business. ## What was wrong `currentUserSessions` called `findActiveSessionsForUser(user.id)` with no workspace filter. Both revoke paths were keyed on `userId` alone, so `revokeUserSession` could target another workspace's session by id and `revokeAllOtherUserSessions` cleared every workspace at once. Sweeping the other resolvers that take `@AuthUser()` turned up the same shape in the OAuth application authorizations added in #23678: `findActiveAuthorizationsForUser(userId)` and `revokeAuthorizationById({ authorizationId, userId })`. A grant made in one workspace was listed, and revocable, from another. Everything else already pairs `@AuthUser()` with `@AuthWorkspace()`. `client-config.resolver.ts` is the reference pattern. ## The fix Both resolvers now take the workspace from the auth context and pass it down, and the service methods are renamed to say so. `revokeAllSessionsForUser` keeps `workspaceId` optional on purpose: `auth.service.ts` uses it on password change, where clearing every workspace is the intended behaviour. Sessions with no workspace (the workspace-agnostic ones minted on the default subdomain, which exists to list workspaces and carry the auto-login window) now belong to no workspace's list and survive "log out all other devices". ## Verification - New integration spec built on Tim's membership of both apple and yc: the list stays disjoint, a cross-workspace revoke by id is refused and is a no-op, and revoking all other devices in one workspace leaves the other signed in - Mutation-checked by dropping the `workspaceId` from the query, which fails the isolation test while the two revoke tests still pass, confirming each assertion targets its own mechanism - 160 unit tests, 70 integration tests across the session and OAuth suites ## Also The devices button drops its danger accent for the plain small variant, matching Deactivate in `ObjectSettings.tsx`. ## Separate finding, not fixed here `request.ip` resolves to an internal address behind the Cloudflare / nginx chain, which is why every row in the screenshot that prompted this showed the same RFC1918 address. That is an ingress configuration issue rather than an application one. It does not affect ClickHouse audit logs, which store no IP, but it does affect the two OAuth rate limiters that key on `req.ip`. --- _Generated by [Claude Code](https://claude.ai/code/session_01C6nCVbcb5ZZrz67uvqvMWF)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23843?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. --> |
||
|
|
7e00298044 |
Rename credits "Increase" button to "Manage" (#23840)
The green primary button in the Credits section was labelled "Increase"
with an up-arrow icon, but it opens the credit package picker, whose
slider spans every available package including smaller ones. So the
label promised upgrade-only while the modal supports both directions.
Renamed it to "Manage" and swapped the up arrow for `IconAdjustments`
(sliders), matching the slider-based picker it opens.
The secondary shortcut buttons ("Increase to $100", "Increase to $200")
are unchanged since those really do apply an upgrade directly.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01CmtdBmWL9eSD3ZX7tgzisZ)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23840?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. -->
|
||
|
|
647a6aec58 |
Add nested relation Field widgets on record page layouts (#23815)
## Context Record pages can show a list of directly related records (Field widget in Table display mode), but not records two relation hops away. The canonical ask: on a Client page, list the Transactions of the Client's Wallets. Stacked on #23814 (merged) and #23832 (merged); their commits are included in this branch. #23836 stacks on this PR to add many-to-one first hops. ## How it works The 2-hop case does not need any new query capability. It reuses the relation traversal filter shipped for advanced filters: the widget embeds a view on the terminal object (Transaction) with one seeded filter `inverse relation IS current record`, traversed one hop (`fieldMetadataId` = Transaction.wallet, `relationTargetFieldMetadataId` = Wallet.client, value `isCurrentRecordSelected`). At query time this compiles to `{ wallet: { clientId: { in: [currentRecordId] } } }`, which is within the backend's `MAX_RELATION_FILTER_DEPTH = 1` since the second hop lands on the join column. Records from all intermediate records (all wallets of the client) are listed, so one-to-many fan-out on the first hop works out of the box. ## Changes Configuration - `FieldConfiguration` gains an optional `nestedRelationFieldMetadataId` (shared type, DTO, GraphQL fragment, regenerated metadata types). Backward compatible: existing widgets are untouched. UI - The Field picker drills into one-to-many relation fields, mirroring the advanced filter submenu pattern: back header, an entry to select the relation itself (previous behavior), then the target object's one-to-many relations. Selecting a nested field creates a widget titled `First hop → Second hop` in Table display mode. First-level rows that open a submenu never show the checkmark; the selected chain is only visible inside the submenu, matching the chart group by field selection. - The layout dropdown, settings panel and renderer resolve the terminal object of the chain; a widget whose second hop was deleted or deactivated renders nothing instead of silently showing first-hop records. - Nested widgets only offer embedded view layouts (Table / Kanban / Calendar), since inline display modes would render the first hop's relation field. - The relation table view resolver regenerates the embedded view whenever the selection results in a table widget and the chain changed or the view id is missing, so a table widget can never carry a view belonging to a different chain. Server - `FieldConfigurationDTO` accepts the new optional field. - Both universal configuration mappers (to and from universal identifiers) carry it for app manifest sync. - New `validateFieldConfigurationNestedRelationOrThrow` enforces that both hops are active one-to-many relation fields on the right objects, wired next to the existing chart field reference validation. Record creation - `buildRecordInputFromFilter` skips relation-traversal filters: they constrain a related record's column, so prefilling the created record's own foreign key from them would link the wrong record (e.g. `walletId = clientId`). - Add New in a nested widget table instead prompts for the record to create through: the row opens a picker listing the current record's first-hop records (the client's wallets), scoped with a find filter on the relation join column, and creates the record with the picked id prefilled. Covers the plain table and per-group add rows. Board and calendar layouts hide their create buttons in nested widgets since they cannot know the record to create through. - Matching the created record against the widget's traversal filter client side is handled by #23832. Out of scope, deliberately: depth stays at exactly two levels (matches the backend filter depth cap), junction and morph relations are not drillable, and chart widgets on record pages are untouched. ## Tests - Unit: nested chain resolution util, draft view seeding with the traversal filter, view id change resolver, picker parameter derivation, server-side validation. Full `page-layout` and `record-filter` front suites pass (187 suites / 1233 tests), server `page-layout-widget` suites pass. - Manual, on seeded data: created a `People → Opportunities` widget on a Company page; it lists exactly the opportunities whose point of contact belongs to that company, persists across save and reload, and scopes per record. Add New opens a picker showing only that company's people; picking one creates an opportunity with `pointOfContactId` set (verified in DB) and the row appears in the widget immediately. --- _Generated by [Claude Code](https://claude.ai/code/session_01Xp3AgGtc4kSP8PpgpKMWLQ)_ <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23815?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> |
||
|
|
c3d0f021b7 |
Fix client-side record matching for nested relation filters (#23832)
## Problem
`isRecordMatchingFilter` assumes every filter keyed by a relation field
name is a flat UUID filter on the related record id. Filters that
traverse a relation, like
```
{ pointOfContact: { companyId: { in: [companyId] } } }
```
(produced by view filters carrying `relationTargetFieldMetadataId`, such
as the seeded filter of a nested relation Field widget in #23815), make
it throw `Unexpected value for UUID filter`. The throw happens inside
the create and update optimistic effects, so creating a record from a
view seeded with such a filter aborts before anything is written.
## Fix
When the value under a relation field name holds related record field
names (or `and`/`or`/`not` composites) instead of UUID operators,
recurse into the related record with the relation target's object
metadata. A related record missing from the payload, or a list relation,
conservatively does not match. Flat UUID filters on the relation name,
join column filters and morph relations keep their existing behavior.
`isRecordMatchingFilter` now takes `objectMetadataItems` to resolve the
relation target metadata. The optimistic effect call sites already had
it in scope; it is threaded through the two group-by helpers.
## Tests
- New `Nested Relation Filters` cases: match, no match, related record
not loaded, composite conditions, list relation.
- Existing suites updated for the added parameter; record-filter and
optimistic-effect suites green, typecheck green.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Xp3AgGtc4kSP8PpgpKMWLQ)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23832?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. -->
|
||
|
|
dac5d5a23a |
Extend live update coverage: workflow draft updates and side-panel records (#23830)
Stacked on #23811. Closes the two remaining live-update coverage gaps found while auditing record-seeded editing surfaces. ## Workflow diagram misses updates to the current draft version `WorkflowSSESubscribeEffect` triggered a content refetch only on `create-one` of a workflow version (new draft created) and on SSE reconnection. Step and trigger edits on the existing draft arrive as `update-one` events and left the open diagram stale until a refresh, which is the common case when the AI chat or a teammate edits a draft workflow. Refetch on `update-one`/`update-many` too. Local workflow mutations do not dispatch these browser events (they only originate from SSE deliveries), and an own-persist echo reseeds the diagram with the state it already shows, so this does not fight local editing. ## Side-panel records receive no SSE events `SidePanelRecordPage` registered no SSE query, so a record opened in the side panel (notes and tasks most commonly) got no events for itself unless another surface happened to subscribe to a matching query. With #23811's rich text adoption this mattered doubly: events could not reach the editor at all. Register the record query like `RecordShowPage` does. The subscribe effect takes a `queryScope` so the record page and side panel keep independent registrations when they display the same record, and closing one surface does not unsubscribe the other. --- _Generated by [Claude Code](https://claude.ai/code/session_018nGvGhFahw1pcefb3P4iCk)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23830?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. --> |
||
|
|
393e62ba9f |
Make AI chat streaming render cost independent of message length (#23831)
Follow-up to #23573: chip-heavy answers are long by design, and each stream flush re-ran `protectChatReferencesForMarkdown` and `marked.lexer` over the whole message, so render cost grew quadratically with message length. This makes the per-flush cost proportional to the appended text instead, and offsets the new code by removing dead AI chat code. ## Streaming render - **Incremental block splitting.** `getMarkdownBlocksIncrementally` reuses blocks that can no longer change and re-tokenizes only the trailing ones. Two trailing blocks stay unstable, not one: a loose list followed by a blank line still merges with a later item (`- a\n\n` + `- b` is one list token). Uses `Lexer.blockTokens` instead of `marked.lexer` since only block raws are needed and the full lexer also runs the inline tokenizer. Simulated stream over a 22 KB chip-heavy message (120 chars/flush, matching the 100 ms flush throttle): 191 ms → 3.7 ms cumulative. The test suite pins char-by-char equivalence against full `marked.lexer` output across loose lists, unclosed fences, setext headings, tables, CRLF and chip markers. - **Per-block reference protection.** `protectChatReferencesForMarkdown` moved behind the existing block memo, so settled blocks never re-run reference parsing during a stream. - **Anchored open pattern.** `(?<!\[)\[\[+` anchors marker matching to the start of a bracket run. The greedy `+` from #23798 backtracked at every position inside a run, once per alternative: 429 ms → ~1 ms on a 10 KB bracket-run input. A run start always yields the same match, so no valid marker is lost. Also an `includes('[[')` bail-out in `findChatReferences`, which runs on every text node of the streaming block. ## Chip lookups `fieldMetadataItemByIdSelector` did `objectMetadataItems.find(obj => obj.fields.some(...))` per chip — O(workspace fields) each time the agent's tool calls trigger a metadata refetch mid-chat. The by-id and by-name map selectors mostly already existed with almost no consumers; this wires `fieldMetadataItemByIdSelector`, `objectMetadataItemFamilySelector` and `viewFromViewIdFamilySelector` to them (adding the missing `objectMetadataItemsByIdMapSelector` and `viewsByIdMapSelector`) and adds `areEqual` so unchanged lookups keep referential stability. ## Offscreen messages Settled messages (everything except the streaming last one) get `content-visibility: auto`, so long threads skip layout and paint for messages scrolled out of view. `contain-intrinsic-size: auto` keeps remembered heights, so scroll positions stay accurate once a message has been painted. ## Removed `ReasoningSummaryDisplay`, `agentChatMessagesComponentState`, `CHAT_THREADS_PAGE_SIZE`, `AgentResponseFormat` and `getFieldIcon` had no consumers. `TextWithChatReferences` and `protectChatReferencesForMarkdown` shared a duplicated segment-slicing loop, now in `getChatReferenceSegments`, and the nine identical per-tag markdown component entries collapse into `createChatReferenceElement`. The branch lands at +354/−329 including the new test suite; production code is net negative. Incidental: `marked` added to jest's `transformIgnorePatterns` allowlist (ESM-only, previously imported by no test). --- _Generated by [Claude Code](https://claude.ai/code/session_01MN8FVc63J4SJQHXWwzUwkh)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23831?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. --> |
||
|
|
c35e364562 |
Fix AI campaign editing: tool auth context in workers and live editor resync (#23811)
Fixes two issues reported when creating an email campaign through the AI chat panel. ## 1. `save_campaign` failed with "Workspace auth context not set" The AI chat streams inside a queue worker job, where no HTTP middleware populates the async-local workspace auth context. The new `MessageCampaignDraftService.saveDraft()` relies on `executeInWorkspaceContext()`'s fallback to `getWorkspaceAuthContext()`, which throws outside HTTP requests. Database CRUD tools worked because `dispatchDatabaseCrud` builds an auth context explicitly; static tools had no equivalent. **Fix:** `ToolExecutorService.dispatch` (the single choke point for all tool executions: preloaded chat tools, `execute_tool`, MCP, workflow agents) now resolves the acting identity once, reusing a provided auth context or building a user context from `userId`/`userWorkspaceId`, and runs the dispatch inside `withWorkspaceAuthContext()`. This mirrors what `WorkspaceAuthContextMiddleware` does for HTTP requests, so tool code can rely on the async-local context on every transport. Side benefit: metadata tools executed from chat previously emitted metadata events with no user attribution (`MetadataEventEmitter` swallows the missing context); they are now attributed correctly. ## 2. AI changes to the open campaign required a page refresh The SSE pipeline delivers worker-originated record updates to the Apollo cache correctly. The campaign editor ignored them: `usePersistedCampaignDraft` seeds local draft state from the record once, and the subject/body/list inputs are uncontrolled (TipTap reads `defaultValue` on mount only). **Fix:** the draft hook now adopts upstream record values while the draft is pristine and exposes a `draftResyncKey` that remounts the `defaultValue`-seeded inputs. Unsaved local edits win over concurrent remote changes (last write wins on flush), and echoes of our own debounced persists never remount inputs mid-typing. ## Tests - `tool-executor.service.spec.ts`: auth context exposed to static tools, provided-context reuse, no-identity passthrough, no context leakage after dispatch, CRUD receives the resolved context, CRUD still rejects without identity. - `usePersistedCampaignDraft.test.tsx`: adopt-when-pristine, own-echo stability, dirty-draft-wins, adopt-after-persist. - `lint:diff-with-main` and `typecheck` clean on both packages. --- _Generated by [Claude Code](https://claude.ai/code/session_018nGvGhFahw1pcefb3P4iCk)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23811?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. --> |
||
|
|
352d7dda55 |
fix(front): apply advanced filter value and operand edits after a fresh page load (#23819)
## Problem On a freshly loaded page (e.g. opening a saved view with an advanced filter), editing an advanced filter rule silently fails: - Toggling a record in a relation value dropdown (e.g. Account Owner \`Is Me\` → adding a workspace member) does nothing: the checkbox does not stick and the filter is never updated. - Changing the operand (e.g. \`Is\` → \`Is not\`) is also a no-op. The edits only work in the session where the rule was just created, which is why this slips through manual testing of new filters. Found while QAing #23718: the new runtime-computed relation chip made the stale value visible enough to notice the edit was never applied. ## Root cause The object-filter-dropdown component states for an advanced filter row live under the row's instance id (\`advanced-filter-<recordFilterId>\`, provided by \`AdvancedFilterRecordFilterRow\`). They are hydrated by \`useSetRecordFilterUsedInAdvancedFilterDropdownRow\` when a rule is created — but never on a later page load. \`AdvancedFilterValueInput\` did write \`objectFilterDropdownCurrentRecordFilter\` & co on dropdown open, but under a different instance id (\`advanced-filter-view-filter-value-input-<recordFilterId>\`) that no dropdown content ever reads — dead writes. So after a reload, \`selectedOperandInDropdown\` is undefined in the instance the dropdown reads, and \`ObjectFilterDropdownRecordSelect.handleMultipleRecordSelectChange\` (gated on it) silently drops the selection. Same story for \`useApplyObjectFilterDropdownOperand\`, which sees no current record filter and never upserts. ## Fix - \`useSetRecordFilterUsedInAdvancedFilterDropdownRow\` now also hydrates \`subFieldNameUsedInDropdown\` and \`relationTargetFieldMetadataIdUsedInDropdown\`, mirroring \`useSetEditableFilterChipDropdownStates\` (the regular filter chip flow, which does not have this bug). - \`AdvancedFilterValueInput\` calls it on value-dropdown open instead of the phantom-instance writes, and its search-input/subFieldName states now target the row instance actually read by the dropdown content. - \`AdvancedFilterRecordFilterOperandSelectContent\` hydrates the same states on operand-dropdown open. ## Test Verified locally against a seeded workspace, on a saved view \`Account Owner Is Me\` reloaded in a fresh session: - Adding a member in the value dropdown now applies immediately: chip updates to \`Me, Aaron Munoz\`, results re-query, Update view appears. - Unchecking \`Me\` leaves \`Aaron Munoz\` with the record-name chip and the filter applied. - Changing the operand to \`Is not\` applies (count flipped from owned-by-me to the complement). - Regular (non-advanced) filter chips unchanged. Ran \`lint:diff-with-main\`, \`typecheck\` and the advanced-filter jest suites. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23819?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. --> |
||
|
|
17ff17bdec |
Dnd library migration fixes and changes (#23752)
Follow-up to #23211. Fixed issues, and simplified where possible. The core idea: every sortable list now resolves its drop position the same way — "sortable over sortable", comparing the pointer against the hovered item's midpoint — instead of each surface owning bespoke droppable slots and end-drop zones. ## Refactors - New `resolveDropFromPointer` handles both axes in one util; items can tag their own `orientation`, so one provider can drive lists of mixed axes. - Dropped `DragDropItemDroppableSlot` and `DragDropItemDropLine` path. Record table/board headers, page-layout tabs & widgets, and fields config all derive the drop index from the hovered sortable, matching record-board cards. - `DragDropItemSortableCell` is now the single sortable primitive, with drag optionally delegated to an explicit `DragDropItemSortableHandle`. - Removed end-drop constants/types; lists now place a trailing append target and resolve the append position in the consumer's own index space. ## Fixes - Dragging a row within grouped records threw an error — the drag overlay now resolves the source row's record-group context. - Multi-select drag counter chip didn't show — drag state was read from the wrong component scope instead of the active `recordIndexId`. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23752?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@twenty.com> |
||
|
|
198ffbb6bd |
Cancel drag activation when the drag source unmounts mid-gesture (#23816)
## Summary Follow-up to the Sentry error [TWENTY-FRONT-HJ8](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-HJ8) (`Cannot start a drag operation without a drag source`), seen on a dashboard page and discussed in #23752. A pointer drag only activates once the pointer travels past the activation constraints (distance/delay). dnd-kit's `PointerSensor` captures the pressed draggable on pointerdown, and when the constraint is satisfied it starts the drag by resolving that draggable's id in the registry. If a re-render unregistered it in between, the lookup fails and `manager.actions.start()` throws. That window is real in our UI: virtualized table rows remount under new per-instance sortable ids, and widgets/tabs remount while a page loads. The breadcrumbs of the Sentry event show the gesture straddling a navigation onto a loading dashboard, with the error firing on the activating `pointermove` 190ms later. ## Fix `PointerSensorWithSourceGuard` extends `PointerSensor` and checks the registry before starting: if the pressed draggable is gone, it cancels the gesture through the sensor's own cancel path (same one dnd-kit wires to activation aborts) instead of throwing. There is nothing left to drag at that point, so cancel is the correct outcome. `DND_KIT_SENSORS` now uses it, which covers every dnd surface. Unit tests pin the behavior with real dnd-kit internals: the base sensor throws in this scenario (documents why the guard exists, and breaks if upstream fixes it so we can remove the subclass), the guard cancels and leaves the operation idle, and a still-registered source starts normally. Fixes TWENTY-FRONT-HJ8 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23816?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. --> |
||
|
|
3f9137bd4e |
Fix widget view save paths dropping relationTargetFieldMetadataId (#23814)
## Context `viewFilter.relationTargetFieldMetadataId` (relation traversal, added in 2.6.0) is accepted and persisted by the `upsertViewWidget` mutation, and `mapViewFiltersToFilters` restores it when loading a widget view. But two frontend mappers silently dropped it, so any relation-traversal filter on a record table widget view was lost the moment the layout was saved (or the moment the user edited the widget's filters in the side panel): - `useSaveRecordTableWidgetViews` omitted the field when building the `upsertViewWidget` input - `useRecordTableWidgetFilterCallbacks` omitted it when syncing current record filters back into the widget view draft ## Changes - Carry `relationTargetFieldMetadataId` through both mappers - Add regression tests for both hooks (they fail without the fix) This is a prerequisite for nested relation field widgets (see follow-up PR), which rely on a traversal filter surviving the widget view save path. --- _Generated by [Claude Code](https://claude.ai/code/session_01Xp3AgGtc4kSP8PpgpKMWLQ)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23814?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. --> |
||
|
|
5b6734691c |
Fix workflow email body overflowing into the attachments section (#23810)
before (introduced by https://github.com/twentyhq/twenty/pull/23657) <img width="381" height="736" alt="Screenshot 2026-08-05 at 15 18 38" src="https://github.com/user-attachments/assets/91d5d1de-bffb-4508-a488-2fe6a09eca61" /> after <img width="1308" height="748" alt="image" src="https://github.com/user-attachments/assets/06a9b875-6cde-47c6-a23f-f9a1f268db42" /> ## Problem In the workflow Send Email action panel, the Body editor painted over the Attachments section when the panel was shorter than the editor's minimum height. |
||
|
|
29e68a7f87 |
Refactor outbound email content compilation (#23782)
## Integration status This is now the final landing PR for the reviewed editor/email architecture stack. | Order | Pull request | Scope | Status | | --- | --- | --- | --- | | 0 | #23657 | Advanced text editor capability presets | Merged into `main` | | 1 | **This PR** | Outbound email content compilation | Ready to land into `main` | | 2 | #23783 | Clean editor surface seam | Reviewed and merged into this branch | | 3 | #23790 | Shared editor block catalog | Reviewed and merged through #23783 | | 4 | #23791 | Canonical TipTap document persistence | Reviewed and merged through #23790 | The current branch tree contains the complete stack. Merging this PR lands all four follow-up layers. ## Architecture The stack establishes four reusable boundaries: 1. **Outbound compilation** — campaign, workflow, and one-to-one/tool email share one compiler, sanitizer policy, renderer, and plain-text derivation path. 2. **Editor surface profiles** — the generic editor owns rendering mechanics while each consuming surface declares chrome, extensions, and explicit compatibility readers. 3. **Shared block primitives** — sections, columns, HTML, images, buttons, and related commands live in the neutral advanced-editor catalog; email behavior is supplied by email schemas/rendering, not by relocating reusable blocks into an email editor. 4. **Canonical persistence** — Twenty-owned authoring persists complete, versioned TipTap JSON documents. HTML, Markdown, plain text, and BlockNote are projections or explicitly owned legacy boundaries. ## Compatibility boundaries Compatibility remains only where shipped data requires it: - workflow Send Email: versionless TipTap JSON, HTML, and plain text - inline email: HTML - AI instructions: Markdown - record rich text: BlockNote arrays and older Markdown/plain text Campaign is unshipped, so its editor, stored rows, sendability validation, and send-time compilation require the current canonical schema version. AI chat drafts are canonical-only local state; old or malformed drafts are rejected at hydration, and plain-text preprompts are converted at their entry point. ## Outbound compiler details The shared compiler owns: - strict structured email-document parsing - React-email rendering - one cached DOMPurify/JSDOM policy for structured and legacy HTML - plain-text derivation from sanitized HTML - single-pass structured-document binding resolution across text, variable tags, links, images, buttons, and raw HTML Resolved workflow values remain inert, legacy workflow and one-to-one HTML remain supported, and Campaign HTML/plain text come from the same compiled result. ## Verification - all automated standard/security reviews passed on the three merged upper PRs with no unresolved threads - shared TipTap/email codec tests: 20 passing - editor, AI draft, and workflow compatibility tests: 14 passing - campaign validation and compilation tests: 31 passing - full shared suite during development: 223 suites / 1,738 tests passing - twenty-front, twenty-shared, and twenty-server typechecks - changed-file type-aware lint and formatting checks |
||
|
|
76bf3651bb |
Fix stray bracket after AI chat chips (#23798)
Chips in the AI chat sometimes rendered with a leftover `]` after them. The reference marker is bracket-asymmetric: it opens with `[[` and closes with `[[/kind]]`, so a complete reference holds four `[` and only two `]`. The model balances that by writing `…[[/object]]]`, and the parser ended the match exactly at the close tag, leaving the extra bracket as prose next to the chip. The parser now absorbs up to as many surplus `]` as the reference opened with, and accepts an opener with extra `[` so an over-wrapped marker doesn't leak one either. The system prompt also tells the model the marker is complete as written. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23798?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. --> |
||
|
|
59672b71b8 |
Add a book-a-call onboarding step for qualified leads (#23521)
https://github.com/user-attachments/assets/76d5a14e-53bd-4195-963b-bf9bb265c8c1 Large-company signups either self-serve a small plan or drop off at the paywall without sales ever seeing them. This adds an embedded Cal.com booking step to onboarding, shown only to leads worth a call. The step sits between Invite Team and the plan step: the lead has built out a workspace by then, and sales gets a chance before checkout. It is always skippable, and a successful booking advances automatically. Qualification reuses the employee count from the People Data Labs enrichment added in #23199. `ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT` sets the bar; leaving it unset means the step never appears. `CALENDAR_BOOKING_PAGE_ID` must also be configured, so the step can never strand someone on an empty embed. Enrichment is no longer gated on `IS_ONBOARDING_AI_CHAT_ENABLED`, since the book-a-call step is now a second consumer of it. `PEOPLE_DATA_LABS_API_KEY` remains the instance-level switch. The existing `/book-call` page is reused: it moves into the onboarding shell and its footer switches between Skip (as a step) and the back link (when reached from the plan page). <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23521?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. --> |
||
|
|
1d755983ff |
Feat/advanced text editor capability presets (#23657)
# Email editor for Compose campaigns
## Short version
Campaign bodies are currently plain rich text. This PR turns the
composer into an email editor: a centered email canvas with section,
column, button, divider, image and raw-HTML blocks, each editable
through a settings side panel, rendered to email-safe HTML per recipient
at send time. Modelled on Resend's Broadcast editor.
**Product**
- Email canvas with page/body styling (background, width, padding,
corner radius, border, text colour, alignment)
- Blocks: section, 2/3 columns, button, divider, raw HTML, images —
insertable from a floating left rail or the slash menu
- A **section is a container whose typography cascades to its
contents**, so one part of an email can have its own look
- Block settings panel focuses whatever you select and shows its
effective values
- Per-recipient variables (`{{firstName}}`, `{{lastName}}`,
`{{fullName}}`, `{{email}}`, `{{personId}}`) usable in text,
button/link/image URLs, image labels and raw HTML
- Image upload by drag-drop, paste or file picker
**Technical**
- Presets now declare **capabilities** instead of surfaces forking the
editor; the UI derives itself from loaded extensions
- Editor behavior lives in `twenty-front`; the versioned email-document
schema and structural traversal live in `twenty-shared`; rendering lives
in `twenty-emails` — HTML is produced server-side per recipient
- Section typography cascade is **resolved at render time**, not left to
CSS: react-email hardcodes `fontSize`/`lineHeight` on every paragraph
and Outlook ignores `inherit`
- Logic vendored from Resend (MIT); all controls rebuilt on `twenty-ui`
+ Linaria
**Also fixes:** the unsubscribe footer was being appended *after*
`</html>`, outside the document, where Gmail strips it — legally
significant since unsubscribe is required.
---
## Detailed version
### Product requirements
**Problem.** The Compose campaign body was a single rich-text field.
Marketing email needs layout — banded sections, columns, call-to-action
buttons, images with links — and it needs that layout to survive
Outlook, which means table-based HTML rather than the divs a text editor
produces. It also needs per-recipient personalisation.
**Reference.** Resend's Broadcast editor, chosen because it solves the
same problem (TipTap authoring → react-email output) and is MIT
licensed.
#### What a user can now do
| Area | Capability |
|---|---|
| Canvas | Email renders as a centered page with its own background,
width, padding, corner radius and border |
| Blocks | Section, 2/3 columns, button, divider, raw HTML, image |
| Insertion | Floating left rail (pointer-first) or the `/` slash menu
(keyboard-first) |
| Sections | Own text colour, font size, line height, letter spacing and
alignment, cascading to everything inside |
| Images | Upload by drag-drop, paste or picker; link URL, alt text,
width, spacing, border |
| Raw HTML | Edited as source in the panel, previewed on the canvas with
scripts neutralised |
| Variables | `{{firstName}}`, `{{lastName}}`, `{{fullName}}`,
`{{email}}`, `{{personId}}` in text, button URLs, link hrefs and raw
HTML |
| Settings panel | Follows selection; shows effective values; opens
automatically when a block is clicked |
#### Deliberate product decisions
- **Variables display as literal placeholders**, not prose labels, so
the syntax is copyable into HTML blocks and button URLs by hand.
- **Sections inherit until they override.** The panel shows what
actually renders rather than blank fields, but writes nothing until you
edit — so changing the body text colour still flows into sections.
- **Headings keep their own scale** inside a styled section; only
colour, family and spacing cascade, otherwise every heading would
collapse to body size.
- **Clicking a block opens its settings**, but only on whole-node
selections, so typing inside a section does not reopen a panel you just
closed.
### Technical strategy
#### 1. Capability presets (the foundation)
Per-surface variation previously worked by **forking**: three separate
`useEditor` call sites with hardcoded extension arrays. Inside the
shared tree there was no variation at all — all five surfaces received a
byte-identical extension list, and presets controlled only sizing,
chrome and serialization format. Adding email blocks that way meant
either leaking section/column nodes into the record rich-text field and
workflow email body, or writing a fourth fork.
Now:
- a preset declares a **capability list** (`basicMarks`, `headings`,
`lists`, `links`, `images`, `campaignVariables`, `slashCommand`,
`blocks`, `mentions`)
- capabilities resolve to extensions through a factory registry
- the UI derives itself from the loaded extensions via
`hasEditorExtension` — no capability list is prop-drilled into a menu,
because the `Editor` already knows what it can do
The acceptance test was collapsing the AI chat fork into an `aiChat`
preset with no visible change to that composer. `campaignBody` is the
only preset opting into the shared `EMAIL_DOCUMENT_CAPABILITIES` today.
Workflow email keeps its current field UI, but can opt into the same
canvas, block settings and image uploader later without adding another
schema or renderer.
#### 2. Schema / renderer split
The hard constraint: **our HTML is produced server-side, per recipient,
at send time**, because variables substitute into nodes rather than into
a serialized string. That rules out Resend's
`renderToReactEmail`-on-the-extension pattern.
```
twenty-front TipTap extensions + node views + shared email settings UI
twenty-shared versioned email-document schema + structural traversal
twenty-emails react-email renderers (imported by twenty-server)
twenty-server surface-specific variable resolution, validation, send
```
Logic was **vendored, not depended on** — Resend's TipTap is 3.17
against our 3.4, and their UI is Radix. We copied the schema/serializer
approach and rebuilt every control on `twenty-ui` + Linaria.
#### 3. Section typography cascade
The subtle part, and the one that would have silently shipped broken.
Section typography *looks* like it should cascade via CSS. It does not:
```js
// react-email's Text
style: { fontSize: "14px", lineHeight: "24px", ...style, ...margins }
```
Every paragraph re-declares `fontSize` and `lineHeight`, overriding any
enclosing section. `inherit` is not a fix either — Outlook's Word engine
ignores it.
So the cascade is **resolved in the renderer**: the tree walk threads
the enclosing section's typography down and writes computed values
explicitly onto each text node. Nested sections refine what they
inherit.
Verified against real rendered output:
| | rendered |
|---|---|
| paragraph inside section | `font-size:22px; color:rgb(255,0,0);
letter-spacing:2px` |
| h1 inside section | `font-size:32px` (own scale) + section colour and
spacing |
| paragraph outside | `font-size:14px`, no colour — untouched |
#### 4. Storage
`bodyTemplate` stays serialized TipTap JSON in a `TEXT` column. Block
attributes are ProseMirror node attrs, so richer blocks add keys to JSON
already being serialized — no migration, and it flows into the existing
500 ms debounced draft save unchanged.
Since the feature has not shipped, the legacy HTML-string body path was
removed rather than maintained. That is a tightening, not just a
deletion: `bodyTemplate` is writable through the record API, and the old
fallback would interpolate an arbitrary string and email it as markup. A
body that is neither empty nor a valid TipTap document is now rejected
at the send gate.
#### 5. Image hosting
Inline assets use an `EmailImage` file folder with
`ignoreExpirationToken: true` and immutable cache headers, because
recipients' mail clients never authenticate and may open an email years
later. The shared uploader returns `{ fileId, url }`; the image node
keeps both the durable file identity and its delivery URL so
ownership/lifecycle or URL resolution can evolve later without a
document migration. The server verifies the uploaded bytes and only
accepts GIF, JPEG, PNG and WebP.
This is intentionally separate from workflow/email **attachments**.
Attachments remain private files that the server reads and embeds as
MIME parts at send time; inline images need a durable recipient-facing
URL. A future workflow canvas should reuse `useUploadEmailImage` for
inline content while keeping its existing attachment control unchanged.
Adding the folder requires three registrations — the folder config, the
route guard's `SUPPORTED_FILE_FOLDERS`, and `DIRECT_UPLOAD_FILE_FOLDERS`
in the upload service.
### Bugs fixed along the way
- **Unsubscribe footer was appended after `</html>`**, outside the
document, where Gmail strips it. Legally significant, since an
unsubscribe link is required. Now inserted before `</body>`.
- **Body text colour never reached the email.**
- **`onImageUpload` was declared but never passed** by any production
call site, so drag-drop and paste image upload were inert everywhere
outside Storybook.
- **Message lists were not user-facing**, so members could not be added
from the list page.
- **Image resize wrote an undeclared `width` attribute** that TipTap
silently dropped.
- **The text bubble menu appeared over selected atom blocks** with
nothing to format.
### Review notes / known limitations
**Security posture to check.** Anything in `EmailImage` is readable by
anyone holding the URL, forever. The server now enforces an image-only
MIME allowlist from sniffed bytes, but it cannot determine whether the
image itself is confidential. This remains a deliberate trade-off for
recipient-visible inline assets.
**Test gap.** The section typography cascade has no regression test:
react-email's `render()` hangs under Jest (tried 60s), and
`twenty-emails` has no test target at all. Verified by rendering through
the built package instead. Adding a test target there is worthwhile
follow-up.
**Sending needs configuration.** `EMAILING_DOMAIN_DRIVER` defaults to
`LOG`, which fakes a messageId, reports any domain as verified, and only
logs — a campaign reaches "sent" with nothing delivered. Real sending
needs `AWS_SES`.
**Unrelated platform bug found.** The pinned "Create new record" command
throws on viewless objects like `messageListMember`, because
`recordIndexId` derives from the current view.
**Not done.** Panel chrome from the reference: breadcrumb (`Page style /
Section`), collapsible groups, per-side spacing grid, and a
variable-insert button inside link fields. All presentation over the
same data.
**Deferred.** Drag-to-reorder blocks.
`@tiptap/extension-drag-handle-react@3.4.2` matches our pinned versions
exactly, so no upgrade is needed, but its behaviour around atom node
views (HTML block, image) is unverified and belongs in its own change.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23657?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>
Co-authored-by: Félix Malfait <felix@twenty.com>
|
||
|
|
5effee7754 |
Fix grouping a view that can no longer be changed or removed (#23619)
Fixes #23529 https://github.com/user-attachments/assets/2dbcf5ac-9b2e-4331-b7e8-703c8c5384b5 Grouping People by Company was a one-way door: once the view was grouped, the grouping could neither be changed nor removed. Two independent bugs on the same path caused it, and both had to be fixed. ## 1. The Group by entry was disabled, so the picker was unreachable `ObjectOptionsDropdownRecordGroupsContent` disabled the `Group by` entry whenever the object had a single groupable field. People exposes exactly one (Company), so the entry was always disabled there. That entry is the only way back to the field picker once a view is grouped: `ObjectOptionsDropdownCustomView` sends `Group` to the picker while the view is ungrouped, and to the group management screen once it is grouped. With the entry disabled, the picker, and with it the `None` option, became unreachable. A table view can always drop its grouping through `None`, so the entry now stays enabled there and is only disabled for layouts that require a grouping. ## 2. The view groups created by the server were never synced back The server deletes and recreates the view groups whenever `mainGroupByFieldMetadataId` changes (`handleFlatViewUpdateSideEffect`), and returns them in the `updateView` payload. `usePerformViewAPIUpdate` only wrote the view itself back to the metadata store, so the `viewGroups` entity kept the pre-change rows. The view create path already syncs them; the update path did not. On top of that, `useHandleRecordGroupField` overwrote the groups returned by the mutation with client-generated ones whose ids matched no persisted row, and `resetRecordGroupField` bailed out on `viewGroups.length === 0`. Since a relation grouping legitimately starts with no groups, clicking `None` was a no-op even when it could be reached. - sync the view groups returned by `updateView` into the metadata store - use those groups instead of regenerating them client-side - reset the grouping based on `mainGroupByFieldMetadataId`, and reload the record index states so the table regroups and ungroups without a refresh ## 3. Drive-by: No Value missing from the widget draft preview `buildDraftViewGroupsForFieldMetadataItem` mirrors `computeFlatViewGroupsOnViewCreate` so the page layout widget preview matches what gets persisted, but it returned early for relation fields and skipped the empty group. The server keeps creating it for nullable fields, relations included, so the group appeared out of nowhere once the widget was saved. It now skips only the option groups and keeps the empty group. ## Not changed Grouping by a relation shows no groups until you add them through `New group`. That is intended, since a relation can have an unbounded number of groups, and nothing here changes it. |
||
|
|
b05d2406ec |
fix(twenty-front): stop field widget layout dropdown from crashing th… (#23784)
…e record page RecordTableWidgetViewDraftInitEffect read the page layout edit mode and the page layout instance id from context, but the widget settings side panel renders outside the page layout tree. Opening the Layout picker on a relation field widget displayed as a table threw "PageLayoutEditModeContext Context not found" and took down the whole record page. Both values are now passed in by the caller. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23784?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. --> |
||
|
|
7c9ec6a770 |
Continue the workspace setup chat in the side panel when navigating away (#23744)
https://github.com/user-attachments/assets/579a8d41-901e-41c0-85a4-f23e6bc3da8d The /workspace-setup full-page chat shows the nav drawer, so users can navigate away mid-conversation and lose sight of the chat. Leaving the page by any means (drawer link, browser back) now opens the same conversation in the Ask AI side panel, with the full-page chat visually shrinking into the panel via the panel's existing width transition. The page marks a handoff atom while mounted; the side panel consumes it in a mount layout effect (pre-paint, so no flash frame), opens the Ask AI page, and enters at full width before shrinking. The Close button still exits without reopening the panel, the Collapse button keeps its behavior and gains the same animation, and prefers-reduced-motion skips it. Mobile is unchanged since the full-screen panel would cover the destination page. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23744?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. --> |
||
|
|
8a856a4bce |
Standardize dragged element feedback (#23772)
## Summary - make dragged table rows use a consistent background across sticky cells - apply one shared opacity treatment to table rows, Kanban cards, and other dnd-kit feedback ## Before/After <img width="1400" height="1980" alt="drag-feedback-before-after" src="https://github.com/user-attachments/assets/ee1fe81f-a762-4343-8db6-c51cf63dbac8" /> |
||
|
|
29aa6e85d6 |
fix(front): only show Discard Draft when workflow has a published version (#23756)
## Problem Draft workflows expose a **Discard Draft** action, but it fails when the draft is the *only* version of the workflow. The backend refuses the delete with `The initial version of a workflow can not be deleted` (guard in `validateWorkflowVersionForDeleteOne`), yet the action was still shown. The display condition and the delete-guard disagreed: - Display condition: `every(selectedRecords, "versions.length")` -> truthy when there is **at least one** version. - Delete guard: forbids deletion unless **another** non-deleted version exists. So a workflow whose only version is a draft showed the button, and clicking it hit a `FORBIDDEN` error. ## Fix Show the action only when the workflow has a published version to fall back to, which mirrors the backend guard: ``` every(selectedRecords, "lastPublishedVersionId") and everyEquals(selectedRecords, "currentVersion.status", "DRAFT") and noneDefined(selectedRecords, "deletedAt") ``` `lastPublishedVersionId` is a plain scalar already on the workflow. `every` (truthiness) is used rather than `everyDefined` because the field is an empty string for never-published workflows, and `everyDefined` would treat `""` as present. The command-menu evaluator reads records straight from the store, and the index/table view only fetches visible columns, so the enrichment provider now also backfills `lastPublishedVersionId` (already fetched by `useWorkflowsWithCurrentVersions`) to keep the condition reliable outside the record show page. ## Existing workspaces The standard-application full sync only runs at workspace creation, so editing the constant alone would fix new workspaces but leave existing ones showing the broken button. A `2.27.0` workspace upgrade command re-syncs the `discardDraftWorkflow` availability expression for existing workspaces, updating it only when it still equals the legacy `versions.length` value (so custom expressions are left untouched). Mirrors the existing 2-23 command-menu-item sync pattern. ## Notes - The `>`-style "more than one version" comparison is not expressible in the current `conditionalAvailabilityExpression` grammar (comparison operators only reach top-level scalars like `numberOfSelectedRecords`, not per-record paths). Gating on `lastPublishedVersionId` achieves the same intent without adding a parser helper. ## Test - Fresh workflow (single draft) -> Discard Draft hidden. - Publish, then edit to create a new draft -> Discard Draft shown and works. - Unit test on the sync-operations builder: updates the legacy expression, no-ops when already synced / custom / missing. |
||
|
|
be051c8724 |
Keep the token pair as a fallback after switching to cookie auth (#23755)
`CookieSessionBootEffect` cleared the token pair the moment it switched a client onto cookie auth. That leaves the client with a single credential, and a server that still has `AUTH_COOKIE_SESSIONS_ENABLED=false` ignores the session cookie entirely — `extractSessionTokenFromRequest` early-returns when the flag is off. A cookie-only client is therefore unauthenticated against such a server, `handleTokenRenewal` finds no refresh token, and `onUnauthenticatedError` signs the user out. That is not a hypothetical state. It is every request routed to a not-yet-rolled pod while the flag is being enabled, and every request after the flag is rolled back. Requests are load-balanced per request, so a migrated client hits an old pod almost immediately and gets signed out; signing back in can migrate it again and repeat for the length of the rollout. It also means rollback was not free, contrary to how it was described: flipping the flag back to `false` signed out everyone who had already migrated, because the pair they were supposed to fall back to had been deleted. ## Approach Keep the token pair as a dormant fallback, and stop *sending* it while cookie auth is active. Both halves are needed. Retaining it without suppressing the header would be worse than the bug: `validateTokenByRequest` checks the Bearer token first and only falls back to the session cookie when there is none, so a client that keeps sending Bearer would never exercise the cookie at all, and `CookieSessionCsrfMiddleware` bypasses on any Bearer-carrying request. Cookie sessions would silently become a no-op. So: - `switchToCookieAuth` no longer nulls the token pair - the auth link omits `authorization` while cookie auth is active, leaving the cookie as the credential in use - on an unauthenticated error while cookie auth is active, the client deactivates cookie auth once per operation and falls through to the existing renewal path, which replays with a fresh Bearer The fallback deliberately goes through renewal rather than replaying immediately: access tokens live 10 minutes, so the retained one has usually expired while the client was authenticating by cookie, and an immediate replay would just fail again. `isCookieAuthActive` is read and written through `localStorage` from the link because the links run per request and must agree with the atom synchronously — a React state update lands a render too late to affect the request being built. ## Follow-up This trades the immediate removal of the token pair from `localStorage` for rollout safety, so the XSS-exfiltration surface that cookie sessions close stays open a while longer. Once cookie sessions are stable across every environment, the retained pair should be dropped — reverting to a clear on `switchToCookieAuth` is a one-line change. ## Test Three cases added to `apollo.factory.test.ts`: no Bearer header while cookie auth is active; an unauthenticated response falls back and replays with the token pair rather than calling `onUnauthenticatedError`; and the fallback is attempted only once before going through renewal. The existing `CookieSessionBootEffect` assertion that the pair is cleared is inverted to assert it is retained. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23755?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. --> |
||
|
|
4b3614b413 |
fix(front): show relation value chip (Me / record names) in advanced filters (#23718)
## Problem
In advanced filters, a relation filter on a workspace-member field (e.g.
Assignee "is Me") displayed its raw JSON value
`{"isCurrentWorkspaceMemberSelected":true,...}` instead of a readable
chip.
Regular (non-advanced) filters handle this correctly:
`EditableRelationFilterChip` computes the label at runtime via
`useComputeRecordRelationFilterLabelValue`, rendering "Me", the selected
record names, or "N members".
The advanced filter value input instead relied on the deprecated stored
`displayValue` through `getRecordFilterDisplayValue`, which has no
`RELATION` branch and falls back to the raw value. When a saved view
filter carries no `displayValue` (it defaults to the raw stringified
value in `mapViewFiltersToFilters`), the raw JSON leaked into the UI.
## Fix
- Extract the relation value-label computation into a shared hook
`useComputeRecordRelationFilterDisplayValue` (parses the relation value,
resolves "Me" + record names).
- `useComputeRecordRelationFilterLabelValue` now consumes it (regular
chips unchanged).
- The advanced filter clickable select renders a dedicated
`AdvancedFilterRelationValueInputClickableSelect` for `RELATION`
filters, computing the label at runtime just like regular filters.
## Proof
Both filter surfaces render the relation value as **Me**, not the raw
`{"isCurrentWorkspaceMemberSelected":...}` JSON. The advanced-filter
shot loads a **saved view in a fresh session** — the exact bug
condition, where the view filter carries no stored `displayValue`.
**Regular filter chip**
<img width="1280" height="760" alt="image"
src="https://github.com/user-attachments/assets/8a867f51-a538-46f2-ba21-a16bb70d85a5"
/>
**Advanced filter**
<img width="1280" height="760" alt="image"
src="https://github.com/user-attachments/assets/b09b9d70-5692-4bac-8cec-3cb006961042"
/>
## Test
Verified manually on a local instance: created a saved view with an
advanced filter `Account Owner Is Me`, then reloaded it in a fresh
session — the condition where the view filter carries no stored
`displayValue`. The value renders as "Me" instead of the raw JSON.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23718?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. -->
|
||
|
|
5bafaa0994 |
Hide onboarding credits when billing is disabled (#23717)
Onboarding advertises free credits (the header pill and the green "Earn +N free credits" tags) even when `IS_BILLING_ENABLED` is false, promising a reward that can never be granted: `creditWorkspaceBalance` already no-ops when billing is off. The server now omits the `onboarding` credit-rewards block from the client config when billing is disabled, which hides every reward tag on its own since they all render behind a defined-config guard. The header pill gets an explicit gate. Also stops treating onboarding invites as reward-eligible when billing is off, so they are minted as plain invitation tokens and the 10-invite `ONBOARDING_INVITE_TEAM_MAX_INVITES` cap no longer applies to self-hosted instances. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23717?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. --> |
||
|
|
6f6da14cc8 |
Stop showing raw chunk load errors (#23571)
On iOS Safari, a failed chunk load showed a snackbar containing the raw browser string `Importing a module script failed.` `PromiseRejectionEffect` snackbars the raw `error.message` of any unhandled rejection, so a floating dynamic import leaked browser internals to the user. It now skips the snackbar for stale chunk errors, which are still captured by Sentry. This only changes what the user sees, not the underlying fetch failure. |
||
|
|
4e3747f3a9 |
fix(twenty-front): expand HTML preview viewport in DocumentViewer (#23668) (#23676)
## Description Fixes #23668. When previewing `.html` or `.htm` files in the file preview modal, `@cyntler/react-doc-viewer` mounts an `iframe` inside `#html-renderer`. Previously, `StyledDocumentViewerContainer` applied `height: 100%; width: 100%;` to `#react-doc-viewer`, `#proxy-renderer`, and `#msdoc-renderer`, but omitted `#html-renderer` and its inner `iframe`. As a result, the preview iframe defaulted to inline iframe bounds instead of expanding to fill the modal container. This PR adds `#html-renderer`, `#html-renderer iframe`, and `iframe` selectors to `StyledDocumentViewerContainer`, ensuring HTML previews expand fully within the modal viewport. ## Testing - Verified StyledDocumentViewerContainer CSS rules target `#html-renderer` and `iframe` elements. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23676?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. --> |
||
|
|
267ecb12db | Migrate web auth from localStorage token pairs to httpOnly cookie sessions (#23642) | ||
|
|
d359496b8b |
Keep chart palette colors stable when series order changes (#23638)
https://discord.com/channels/1130383047699738754/1522812783140540538 Chart palette colors were assigned by array position, so changing the sort order (or any reordering of the data) reshuffled every series color on line, bar and pie charts. Grouping by a select field was unaffected since options carry their own colors — this only hit groupings without intrinsic colors (relations, text fields, etc). Colors are now assigned by the alphabetical rank of the series key, so a key keeps its color no matter what order the data arrives in. Side effect: existing palette-colored charts get a one-time color reassignment. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23638?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. --> |
||
|
|
2389b4f807 |
Add captcha and throttling to the password reset link (#23372)
The public `emailPasswordResetLink` mutation was the only email-taking auth mutation without `CaptchaGuard`, so bots could drive reset email spam against arbitrary addresses. - Adds `CaptchaGuard` and a `captchaToken` argument (no-op when no captcha provider is configured). The frontend sends it like sign-in does, and `/settings/profile` joins the captcha-protected paths so the Change Password button keeps working - Throttles reset emails per address, 3 per 15 minutes, and surfaces a rate limit error once the bucket is empty - Acknowledges the request as soon as the throttle passes and generates the link off the request path, so the response time no longer depends on whether the address is registered - Returns a generic success instead of distinguishing found from not-found, with matching frontend copy - Rotates the reset token in a single transaction, so a failed write can no longer revoke a still valid link This does not close user enumeration on its own: `checkUserExists` exposes `exists` on the same unauthenticated surface, and sign-in returns distinguishable errors. Tracked in #23711. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23372?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. --> |
||
|
|
22d83c75e6 |
fix(twenty-front): Scrolling and Dragging conflict on mobile devices (#23677)
Fixes: #23675 https://github.com/user-attachments/assets/1f0d4f0f-0dd6-4731-8359-6da13a5e11b3 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23677?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> |
||
|
|
e81fdbcc7a |
feat(workflow): variable pickers for Search Records limit, offset and date filters (#23696)
## Summary <img width="491" height="390" alt="Capture d’écran 2026-08-03 à 11 30 40" src="https://github.com/user-attachments/assets/6db59b8a-3e8e-41b8-80b3-c736e56b7f7f" /> Adds workflow variable pickers to the **Search Records** action for fields that previously only accepted static values: - **Limit** and **Offset** number inputs now expose the `WorkflowVariablePicker`, so they can be bound to a variable from a previous step. The stored value can be a standalone variable string; the backend coerces the resolved value back to a number. - **Date filters** using the `Is before` (`IS_BEFORE`) and `Is after or equal` (`IS_AFTER`) operands now expose the variable picker in the advanced filter side panel (previously disabled for all date filters). The backend already resolves these inputs via `resolveInput`; the only backend change is a small numeric coercion of the resolved limit/offset. ## Changes - `WorkflowEditActionFindRecords.tsx` — pass `WorkflowVariablePicker` to the Limit/Offset inputs; make `onChange` and form state variable-aware (`number | string`). - `AdvancedFilterSidePanelValueFormInput.tsx` — enable the date `VariablePicker` only for `IS_BEFORE` / `IS_AFTER`. - `useGetRecordFilterDisplayValue.ts` — return the raw variable for a standalone `{{variable}}` value so date filters don't crash `Temporal.*.from`. - `find-records-action-settings-schema.ts` — allow a string (variable) for `limit` / `offset`. - `find-records.workflow-action.ts` — coerce resolved `limit` / `offset` to numbers before querying. ## Testing Built a workflow locally (Manual trigger → Code step returning `{ limit: 2, offset: 1, sinceDate }` → Search Records) with all three fields bound to those variables. The run completed successfully; the Search Records step returned exactly 2 records (limit applied) filtered by `createdAt >= sinceDate`, confirming the backend resolves each variable. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23696?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. --> |
||
|
|
536b91c1dd |
Update record page layout card (#23654)
## Summary - Redesign the data-model Layout card to match the record-page customization design. - Add dedicated light and dark cover assets and preserve the existing customization workflow. - Reuse a dedicated discovery-hero footer across the workspace and record layout cards. - Match the Figma cover, footer, icon, typography, and spacing metrics. ## Before/After <img width="1588" height="720" alt="before-after" src="https://github.com/user-attachments/assets/28be577c-9f1b-40f1-99e2-66eeafbac75b" /> |
||
|
|
f663cd3c68 |
Move open-record-in to object metadata and member preference (#23614)
Replaces the per-view "Open in" setting with a two-level model, following up on #23422 / #23424 and superseding the closed #23446 and #23457: - `objectMetadata.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` | `USER_CHOICE` (default `USER_CHOICE`) - `workspaceMember.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` (default `SIDE_PANEL`), editable in Settings > Experience The rule: records open where the member prefers, unless the object pins them, and never in a panel there is no room for (mobile always resolves to the record page). ## Why Having the setting on views, objects and members at once was heavy, and view-level resolution was fragile: a chip rendered outside a view (notes, front components, kanban cards pointing at another object) had no view to read from, which is the class of bug behind #23422. Resolution is now context-free: it needs only the object, the current member and the viewport, so chips behave identically everywhere by construction. ## Changes **Object level** - New `openRecordIn` enum column on `objectMetadata`, editable through `updateOneObject` and surfaced in Settings > Data model > Object > Layout ("Open records in": Member preference / Side Panel / Record Page) - Standard definitions pin `workflow`, `workflowVersion`, `dashboard` and `messageCampaign` to the record page (matching the previously hardcoded list) and `calendarEvent` to the side panel (it has no curated record page); everything else, including `workflowRun`, follows the member preference - Apps can set it in `defineObject()` via the object manifest **Member level** - New `openRecordIn` standard field on `workspaceMember`, persisted through the existing settings path (same as `colorScheme`) and exposed in Settings > Experience **View level (deprecated)** - `view.openRecordIn` is no longer read or written by the frontend; the "Open in" entry is gone from the view options dropdown - The column, DTO field and inputs are kept for one release for API compatibility: the output field carries a `deprecationReason`, the inputs keep accepting the value with a `Deprecated:` description (NestJS silently drops input fields that have a `deprecationReason`, which would have been a breaking change) **Upgrade (2.27)** - Fast instance command adds the `objectMetadata.openRecordIn` column defaulting to `USER_CHOICE` - Workspace command adds the `workspaceMember.openRecordIn` field - Workspace command seeds the object column from the standard definitions (any non-`USER_CHOICE` value), then lifts deliberate per-view record page choices onto objects the definitions don't pin **Debt removed** - `canOpenObjectInSidePanel` hardcoded object list and its test - `ObjectOptionsDropdownLayoutOpenInContent` and the `layoutOpenIn` dropdown wiring - `DefaultViewOpenRecordIn` - Context-store/view-based resolution in `useResolveOpenRecordIn` (now reads object metadata + member + viewport) - Front components no longer guess from the current view: an explicit side-panel call honours a pinned object and the viewport, nothing else ## Verification - Ran the three upgrade commands against a live database: column created, the pinned standard objects seeded per workspace (record page pins plus calendarEvent to side panel), member field backfilled to `SIDE_PANEL`; seed rerun is a no-op - Seed command verified on a simulated pre-upgrade workspace (index view set to record page on company): pins the standard objects plus company, idempotent on rerun - Both packages typecheck and lint clean; affected unit suites and the application sync, view creation and metadata cache integration specs pass --------- Co-authored-by: Thomas des Francs <tdesfrancs@gmail.com> |
||
|
|
706d72e53e |
fix(front): reload record board groups when view groups change (#23637)
## Problem Fixes #23462 On a Kanban (record board) view grouped by a SELECT field, adding a new option to that field creates the column, but dragging a record into the new column silently fails (no move, no error) until a hard page reload. ## Root cause `RecordIndexLoadBaseOnContextStoreEffect` builds its load key from the view id and the calendar-week flag only: ``` `${contextStoreCurrentViewId}-${isCalendarWeekViewEnabled}` ``` The effect early-returns when `loadedViewKey === currentViewLoadKey`. When a new `ViewGroup` is created from the added SELECT option, the view id does not change, so the key is unchanged and `loadRecordIndexStates` never re-runs. The record group state (`recordGroupIdsComponentState` / `recordGroupDefinitionFamilyState`) stays stale, so the drop handler cannot resolve the new group's field value and the move no-ops. A hard reload fixes it because the view then loads with the new group present from the start. ## Fix Include a signature of `view.viewGroups` (ordered `id:position:isVisible`) in the load key so the effect re-runs `loadRecordIndexStates` whenever the view's groups change, not only when the view id changes. The calendar-week flag is kept in the key. ## Testing Verified end to end on a local instance against an Opportunities "By Stage" Kanban, with the record's stage change confirmed in the database: - **Before the fix:** add a new Stage option in-session, then drag a record into the new column. Dragging into an existing column persists the move; dragging into the newly created column does nothing (record's stage unchanged in DB). - **After the fix:** same flow, dragging a record into the newly created column moves it and persists the new stage in DB, with no reload. `nx typecheck twenty-front` and `oxlint` pass. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23637?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. --> |
||
|
|
d02332834b |
Fix record title cell reopening on every page refresh (#23551)
## What `location.state.isNewRecord` is set when navigating to a freshly created record so the title cell opens for naming. But router state lives in **browser history state, which survives page refreshes** — so every refresh of that record's page re-opens the title cell with an empty draft and a blinking cursor. Strip the flag after its one intended consumption in `PageChangeEffect` (react-router keeps user state under `history.state.usr`). ## Repro (on current main, any view set to open records in record page — or on mobile) 1. Create a record from a table; you land on its record page with the title focused (intended). 2. Name it, click away, then refresh the page. 3. The title cell re-opens, empty, focused — on every refresh, forever. 🤖 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/23551?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: bosiraphael <raphael.bosi@gmail.com> |
||
|
|
3ed11054a0 |
Keep leading + when filtering phones by calling code (#23546)
Fixes #23528 Filtering a PHONES field with `CONTAINS` / `DOES_NOT_CONTAIN` stripped every non-digit character from the filter value, so `+33` became `33` and the generated `ilike`/`like` predicates could not distinguish an international calling code from any number containing those digits. `turnRecordFilterIntoGqlOperationFilter` now preserves a leading `+` while still removing other formatting characters (spaces, dashes, parentheses). `+33 6 12` becomes `+33612`; values without a `+` are unchanged. Added a regression test in `computeViewRecordGqlOperationFilter.test.ts` for a `+`-prefixed value. Lint and typecheck pass on `twenty-shared` and `twenty-front`; the filter test suites pass in both packages. --------- Co-authored-by: Thomas Trompette <tom@twenty.com> |
||
|
|
8f9f2f390e |
fix(ai-chat): show streaming activity during and between steps (#23581)
https://github.com/user-attachments/assets/e72e313c-66f8-40af-bf48-9225422ffa78 ## Problem During a streaming turn with tool calls, the chat goes completely static in two places: - **Between two steps**: once a tool's output arrives, its row flips to past tense and nothing animates until the model's next chunk arrives (a full LLM round trip, often several seconds). This window is defined by the absence of parts, so no part-driven component can fill it — and the pre-turn "…" indicator can't either, since it's cleared on the turn's first chunk and never comes back. - **During tool execution**: the active tool row in `ThinkingStepsDisplay` is a static icon + label; the only animated element there is the orbit loader on an actively-streaming reasoning part. Users can't tell whether the AI chat is still thinking or blocked. ## Fix - **Pending thinking row between steps.** The renderer flags the trailing thinking-steps group of a streaming, error-free message (`showPendingThinkingRow`), and `ThinkingStepsDisplay` appends the thinking row (orbit loader + "Thinking") inside its rows container when none of its own steps is active (`isThinking`, which it already computes). The row occupies the exact slot where the next real step row materializes, so the handoff happens in place with no layout shift. - **One shared row component.** `AiChatThinkingRow` renders the orbit loader + "Thinking" and is used both for an actively-streaming reasoning step and for the pending row. - **Shimmer on executing tools.** Active tool rows wrap their label ("Searching the web for…") in the existing `ShimmeringText` while awaiting output, with the text as a direct child of the background-clip element so the effect applies reliably. - **Activity derived from the tool lifecycle state.** `isThinkingStepPartActive` now checks `input-streaming` / `input-available` instead of output presence, so a tool completing with a legitimate `null` output is no longer classified as still running. Why the trailing-group check is sufficient: anything in progress outside the group — streaming answer text, a running code execution card, a pending question — is itself a later render item, so the group isn't last and never gets flagged. No message-wide part scanning needed. ## Notes - The row renders only while `agentChatIsStreaming`, which the existing keepalive watchdog force-clears (with a visible connection-lost error) after ~5s of subscription silence — it cannot spin forever on a dead stream. - It never shows while waiting on the user: `ask_questions` renders as its own item after the group, and the server ends the stream on that tool anyway (`stopWhen`). - Consciously not covered, for simplicity: a pause right after a mid-turn text part or right after the routing row. ## Tests - Renderer: trailing group flagged as pending while streaming; not flagged when answer text follows or when not streaming - `ThinkingStepsDisplay`: pending row appended after completed steps, suppressed while a tool step runs, loading label shown on a running tool - `isThinkingStepPartActive`: lifecycle-state cases, including a completed tool with `null` output Lint, format, and `typecheck twenty-front` are clean. |
||
|
|
a9084604b4 |
Use the fast model for the onboarding setup chat (#23586)
The workspace setup chat ran on the smart model. The hidden kickoff turn enqueued its job without a `modelId`, and the frontend sends none unless the user picks one, so every turn fell through to `modelId ?? workspace.smartModel` in `chat-execution.service.ts`. Two halves, since the kickoff is server-initiated and the frontend never sends it: - `startHiddenKickoffStream` takes a `modelId` and the setup chat passes `workspace.fastModel`. - `useAgentChatModelId` requests `workspace.fastModel` on the setup page, so user turns follow. Everywhere else it still sends nothing and the server fallback is unchanged. `workspace.fastModel` defaults to the `default-fast-model` sentinel, so the model still resolves through the registry and stays admin-overridable. An explicit pick from the model picker still wins. |
||
|
|
53a18d7528 |
feat(workflow): route all version content readers through the flag-aware sources (#23583)
## What Follow-up to #23499. Migrates every remaining reader of `record.trigger`/`record.steps` so all version content flows through the flag-aware sources, then removes `trigger`/`steps` from the record field sets. The record CRUD path no longer carries version content anywhere in the app. Reading is still entirely behind `IS_WORKFLOW_VERSION_IN_CORE_ENABLED`: this PR changes who asks, never where the answer comes from. Flag off remains record reads (via the content hook's record branch), flag on the core query. ## Per reader | reader | now reads | | --- | --- | | `WorkflowDiagramCanvasEditable` (connect, drag-stop) | flow atom | | `useDeleteStep` | flow atom | | `WorkflowEditActionIfElseBody` (branch cleanup) | flow atom | | `SidePanelWorkflowCreateStepContent` (parent-step lookup) | flow atom | | `SidePanelWorkflowStepInfo` | flow atom (explicit instance id), falling back to `useWorkflowVersionContent` when the visualizer is not mounted | | `TestWorkflowSingleRecordCommand` | `useWorkflowVersionContent`; `ready` gates on content being loaded | | headless enrichment hook (imperative) | core content query when the flag is on, record otherwise | | `WorkflowRunVisualizerEffect` (step output schemas) | the run snapshot (`state.flow`), which is what a run should show anyway | ## Field-set slimming `useWorkflowVersion` and `useWorkflowWithCurrentVersion` stop fetching `trigger`/`steps` (identity fields only). Three call sites lost their only reason to call `useWorkflowWithCurrentVersion` and were dropped entirely. Verified by grep that no `currentVersion.trigger/steps` reads remain; the only remaining `.trigger`/`.steps` accesses are argument-taking utils whose callers now pass flow/content-sourced objects. ## Verification - `nx typecheck twenty-front` green - Front tests: 1058 green (the enrichment test gained mocks for the apollo client and flag its hook now uses) - Full-tree `oxfmt` + `oxlint --type-aware` green (3 remaining warnings are pre-existing in unrelated record-field files) - Live click-through pending, flag off and on <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23583?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. --> |
||
|
|
0d63906a58 |
Fix calendar field picker state handling (#23595)
## Context Changing a calendar date field already updated the record-index calendar state immediately, so the calendar moved to the new field before the `updateView` mutation completed. The options dropdown still derived its selected checkmark and field labels from `currentView`, which remains unchanged while persistence is pending. On slower environments this left the previous field name visible even though the calendar was already using the new field. Locally the same mismatch existed, but was only visible briefly because the mutation completed faster. ## What changed - Read the active start and end date field IDs from the record-index calendar component state in the calendar options dropdown. - Use that state for the main options label, the two-field submenu, and both field-picker selections. - Use the optimistic end-field state when filtering compatible start fields and deciding whether an incompatible end field must be cleared. - Keep the existing view mutation and calendar-state writes unchanged. ## Why The calendar and its configuration UI now share the same source of truth while persistence is pending. A field selection updates the calendar, checkmark, and contextual labels together instead of temporarily mixing optimistic calendar state with stale persisted view metadata. ## Safety and expected impact This is frontend state synchronization only. It does not change the metadata schema, API payloads, or persistence flow. Existing date and datetime compatibility rules remain in place. Users should see the selected field name update immediately, including when the metadata mutation is slow. ## Limitations This does not change mutation error handling or add rollback behavior. The calendar atoms were already updated optimistically before this change, this PR only makes the configuration UI reflect those same values. ## Validation - Reproduced the stale selection on qacoco and locally. - Verified locally that the checkmark moves immediately after selecting another date field, before the mutation closes the dropdown. - `npx nx typecheck twenty-front` - Focused type-aware oxlint on the four changed files, 0 warnings and 0 errors. - `npx oxfmt --check` on the four changed files. - `git diff --check` |