Rams MCP · The full engine, now in your coding agent
twentyhq on GitHub

twentyhq/twenty

Reviewed against Rams quality heuristics: accessibility, color, typography, spacing, components, motion, UX, and craft.

30 files reviewed·July 9, 2026

Top fix

Gate Ctrl/Cmd+Enter behind the same canSend check as the button

See the fix

Verdict

Layout and hierarchy are consistently clean, but every edge case in the send flow renders blank or silently misbehaves. The biggest risk is a hotkey that can send a campaign the button itself refuses to send.

Files Rams reviewed

packages/twenty-front/src/modules/side-panel/pages/compose-campaign/components/SidePanelCampaignComposerPage.tsx

packages/twenty-front/src/modules/side-panel/pages/compose-email/components/SidePanelComposeEmailPage.tsx

packages/twenty-front/src/modules/side-panel/pages/front-component/components/SidePanelFrontComponentPage.tsx

packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelChartFilterSubPage.tsx

packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelFieldRelationTableFieldsSubPage.tsx

packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelFieldsLayoutSubPage.tsx

packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/SidePanelRecordTableFieldsSubPage.tsx

packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/SidePanelRecordTableFilterSubPage.tsx

packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/SidePanelRecordTableSortSubPage.tsx

packages/twenty-front/src/modules/side-panel/pages/record-page/components/SidePanelMergeRecordPage.tsx

packages/twenty-front/src/modules/side-panel/pages/record-page/components/SidePanelRecordPage.tsx

packages/twenty-front/src/modules/side-panel/pages/rich-text-page/components/SidePanelEditRichTextPage.tsx

packages/twenty-front/src/modules/side-panel/pages/search/components/SidePanelSearchRecordsPage.tsx

packages/twenty-front/src/pages/settings/applications/components/SettingsAppModalLayout.tsx

packages/twenty-front/src/modules/side-panel/pages/ai-chat-threads/components/SidePanelAiChatThreadsPage.tsx

packages/twenty-front/src/modules/side-panel/pages/ask-ai/components/SidePanelAskAiPage.tsx

packages/twenty-front/src/modules/app/components/App.tsx

packages/twenty-front/src/modules/app/components/DomainShell.tsx

packages/twenty-front/src/modules/app/components/I18nActivationGate.tsx

packages/twenty-front/src/modules/app/components/RootAppProviders.tsx

packages/twenty-front/src/modules/app/components/WorkspaceAppProviders.tsx

packages/twenty-front/src/modules/side-panel/pages/common/components/SidePanelSubPageNavigationHeader.tsx

packages/twenty-front/src/modules/side-panel/pages/page-layout/components/CanvasTabSettingsContent.tsx

packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartFiltersDeletedFieldsWarning.tsx

packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartFiltersSettings.tsx

packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartFiltersSettingsInitializeStateEffect.tsx

packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartLimitInfoBanner.tsx

packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartSettings.tsx

packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartTypeSelectionSection.tsx

packages/twenty-front/src/modules/side-panel/pages/page-layout/components/NewFieldDefaultVisibilityToggle.tsx

90/100

UX

5 serious
UX·packages/twenty-front/src/modules/side-panel/pages/compose-campaign/components/SidePanelCampaignComposerPage.tsx:54Serious

Send campaign button gives no feedback while the request is in flight

The 'Send campaign' Button (lines 54-64) toggles only on `disabled={!campaignState.canSend}`. There is no in-flight/loading state passed to the button, so nothing on screen changes between the click and the panel closing.

Why it matters

Without a visible pending state, users who don't see an instant response click again or hit the hotkey again, risking a duplicate send request during network latency.

Fix

Disable the button and show a loading indicator for the duration of the async send call.

<Button
  key="send"
  size="small"
  variant="primary"
  accent="blue"
  title={t`Send campaign`}
  Icon={IconSend}
  hotkeys={[getOsControlSymbol(), '⏎']}
  onClick={campaignState.handleSend}
  disabled={!campaignState.canSend}
/>,
<Button
  key="send"
  size="small"
  variant="primary"
  accent="blue"
  title={campaignState.isSending ? t`Sending…` : t`Send campaign`}
  Icon={IconSend}
  hotkeys={[getOsControlSymbol(), '⏎']}
  onClick={campaignState.handleSend}
  disabled={!campaignState.canSend || campaignState.isSending}
/>,
UX·packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelFieldsLayoutSubPage.tsx:30Serious

Blank render when no widget is in edit mode confuses the panel

`SidePanelFieldsLayoutSubPage` returns `null` whenever `widgetInEditMode` is undefined: - No loading indicator while the widget resolves - No empty/error state if the user reaches this sub-page with nothing to edit Both the transient loading case and the truly-empty case render identically: a blank side panel.

Why it matters

A user opening this side panel sees nothing, with no signal whether the app is still loading or whether they hit a dead end. That reads as broken rather than deliberate, and it costs debugging time later when someone has to figure out which of the two states actually occurred.

Fix

Render a distinguishable loading state instead of silently returning null.

if (!isDefined(widgetInEditMode)) {
  return null;
}
if (!isDefined(widgetInEditMode)) {
  return <StyledFieldsLayoutContainer aria-busy="true" />;
}
UX·packages/twenty-front/src/modules/side-panel/pages/compose-email/components/SidePanelComposeEmailPage.tsx:82Serious

Cancel discards a drafted email with zero confirmation

The "Cancel" button calls `onClick={goBackFromSidePanel}` directly, immediately navigating away and abandoning whatever the user typed into the composer fields.

Why it matters

A drafted email is destroyed silently the moment a user misclicks "Cancel", with no way to recover the lost content.

Fix

Confirm before discarding unsaved compose content, or route through the same guard used elsewhere for destructive navigation.

<Button
  key="cancel"
  size="small"
  variant="secondary"
  title={t`Cancel`}
  onClick={goBackFromSidePanel}
/>,
<Button
  key="cancel"
  size="small"
  variant="secondary"
  title={t`Cancel`}
  onClick={() => {
    if (composerState.hasUnsavedChanges) {
      openDiscardDraftConfirmation(goBackFromSidePanel);
    } else {
      goBackFromSidePanel();
    }
  }}
/>,
UX·packages/twenty-front/src/modules/side-panel/pages/front-component/components/SidePanelFrontComponentPage.tsx:32Serious

Lazy-loaded component has no visible loading state during fetch

The `<Suspense fallback={null}>` wraps the lazily imported `FrontComponentRenderer` but renders nothing while the chunk downloads.

Why it matters

Users see a blank side panel with no indication anything is happening, which reads as broken rather than loading, especially on slower connections where the JS chunk takes time to fetch.

Fix

Give Suspense a lightweight skeleton or spinner fallback instead of null.

<Suspense fallback={null}>
  <FrontComponentRenderer
    frontComponentId={viewableFrontComponentId}
    selectedRecordIds={selectedRecordIds}
  />
</Suspense>
<Suspense fallback={<SidePanelFrontComponentSkeleton />}>
  <FrontComponentRenderer
    frontComponentId={viewableFrontComponentId}
    selectedRecordIds={selectedRecordIds}
  />
</Suspense>
UX·packages/twenty-front/src/modules/side-panel/pages/compose-campaign/components/SidePanelCampaignComposerPage.tsx:33Serious

Ctrl/Cmd+Enter can send a campaign even when the button says it can't

The 'Send campaign' button gates its click with `disabled={!campaignState.canSend}` (line 63), but the `useHotkeysOnFocusedElement` callback at line 35 calls `campaignState.handleSend` directly with no `canSend` check. The keyboard path and the mouse path enforce different rules for the same action.

Why it matters

A user who hits Ctrl+Enter while the form is incomplete can fire a send the UI visibly disabled, producing an unintended submit or a confusing failure with no on-screen explanation.

Fix

Gate the hotkey callback with the same canSend condition that disables the button.

useHotkeysOnFocusedElement({
  keys: ['ctrl+Enter,meta+Enter'],
  callback: campaignState.handleSend,
  focusId: SIDE_PANEL_FOCUS_ID,
  dependencies: [campaignState.handleSend],
});
useHotkeysOnFocusedElement({
  keys: ['ctrl+Enter,meta+Enter'],
  callback: () => {
    if (campaignState.canSend) {
      campaignState.handleSend();
    }
  },
  focusId: SIDE_PANEL_FOCUS_ID,
  dependencies: [campaignState.handleSend, campaignState.canSend],
});
98/100

Components

1 serious
Components·packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelFieldRelationTableFieldsSubPage.tsx:18Serious

pageLayoutId skips the isDefined guard applied to its two siblings

`widgetInEditMode` and `viewId` both get an `isDefined` check before use, but `pageLayoutId` from `usePageLayoutIdFromContextStore()` is passed straight into `useWidgetInEditMode(pageLayoutId)` and down to `RecordTableSettingsFieldVisibility` with no equivalent check.

Why it matters

If the context store hasn't populated `pageLayoutId` yet, it flows into a hook and a child component that likely expect a real ID, producing an inconsistent guard pattern that's easy to miss when debugging a downstream failure.

Fix

Guard pageLayoutId with the same isDefined check used for the other two derived values before rendering the child.

const { pageLayoutId } = usePageLayoutIdFromContextStore();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const { pageLayoutId } = usePageLayoutIdFromContextStore();

if (!isDefined(pageLayoutId)) {
  return null;
}

const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);

Accessibility

No issues found

Typography

No issues found

Color

No issues found

Spacing

No issues found

Motion

No issues found

Craft

No issues found

Working well

  • The `resolvedFieldsConfiguration` fallback (checking `configurationType === WidgetConfigurationType.FIELDS` before casting, otherwise using `temporaryFieldsConfiguration`) is a real discriminated-union narrowing rather than a blind `as any` cast, so a mismatched configuration type can't silently pass through as fields data.
  • The footer correctly differentiates 'Cancel' (variant="secondary") from 'Send campaign' (variant="primary", accent="blue"): this is exactly right because the eye should land on the one action that commits the campaign, and a secondary treatment on Cancel keeps it available without competing for attention.
  • The footer pairs a "secondary" variant Cancel button with a "primary" blue Send button. This is the correct pattern: the return-to-previous action stays visually quiet while the committing action (Send) carries the full weight, so users scan the footer and know instantly which button moves them forward.
  • The Send button surfaces its keyboard shortcut inline via `hotkeys={[getOsControlSymbol(), '⏎']}`, and the same shortcut is wired through `useHotkeysOnFocusedElement`. Showing the shortcut next to the label it triggers is what makes power-user shortcuts discoverable instead of hidden trivia.

Score your own repo with Rams.

Free on public repos. Rams reviews every pull request for accessibility, color, type, spacing, components, motion, UX, and craft, and posts inline fixes.