diff --git a/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx b/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx
index 80dbdddab7..914ddeedcb 100644
--- a/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx
+++ b/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx
@@ -120,7 +120,7 @@ Import them from `twenty-sdk/front-component`:
- **`Command`** — Runs an async callback via the `execute` prop.
- **`CommandLink`** — Navigates to an app path. Props: `to`, `params`, `queryParams`, `options`.
- **`CommandModal`** — Opens a confirmation modal. If the user confirms, executes the `execute` callback. Props: `title`, `subtitle`, `execute`, `confirmButtonText`, `confirmButtonAccent`.
-- **`CommandOpenSidePanelPage`** — Opens a side panel page. Props depend on `page` — e.g. `ViewRecord` takes `recordId` + `objectNameSingular`, other pages take `pageTitle` + `pageIcon`.
+- **`CommandOpenSidePanelPage`** — Opens a side panel page. Props depend on `page` — e.g. `ViewRecord` takes `recordId` + `objectNameSingular` (plus an optional `tab` id to open the record on a specific tab), other pages take `pageTitle` + `pageIcon`.
Here is a full example of a headless front component using `Command` to run an action from the command menu:
@@ -194,6 +194,44 @@ export default defineFrontComponent({
});
```
+And an example using `CommandOpenSidePanelPage` to open the current record in the side panel on a specific tab. `tab` is a page layout tab id (default layouts use ids like `company-tab-emails` or `company-tab-timeline`; custom layouts use the tab's own id). If the id doesn't exist in the record's layout, the default tab opens instead:
+
+```tsx src/front-components/open-company-emails.tsx
+import { defineFrontComponent } from 'twenty-sdk/define';
+import {
+ CommandOpenSidePanelPage,
+ SidePanelPages,
+ useSelectedRecordIds,
+} from 'twenty-sdk/front-component';
+
+const OpenCompanyEmails = () => {
+ const selectedRecordIds = useSelectedRecordIds();
+ const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
+
+ if (!recordId) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export default defineFrontComponent({
+ universalIdentifier: 'b8c9d0e1-f2a3-4567-bcde-678901234567',
+ name: 'open-company-emails',
+ description: 'Opens the current company on its Emails tab',
+ component: OpenCompanyEmails,
+ isHeadless: true,
+});
+```
+
## Calling a logic function
Front components run browser-side in a Web Worker sandboxed inside an opaque-origin iframe, while [logic functions](/developers/extend/apps/logic/logic-functions) run server-side. There is no direct in-process call between the two — instead, a front component reaches a logic function over HTTP.
diff --git a/packages/twenty-front/src/modules/front-components/hooks/__tests__/useFrontComponentExecutionContext.test.tsx b/packages/twenty-front/src/modules/front-components/hooks/__tests__/useFrontComponentExecutionContext.test.tsx
index 40bf93ed84..67a1d2b9d8 100644
--- a/packages/twenty-front/src/modules/front-components/hooks/__tests__/useFrontComponentExecutionContext.test.tsx
+++ b/packages/twenty-front/src/modules/front-components/hooks/__tests__/useFrontComponentExecutionContext.test.tsx
@@ -27,6 +27,7 @@ const mockEnqueueWarningSnackBar = jest.fn();
const mockCloseSidePanelMenu = jest.fn();
const mockSetCommandMenuItemProgress = jest.fn();
const mockCopyToClipboard = jest.fn();
+const mockSetRecordPageActiveTabId = jest.fn();
let mockCurrentUser: { id: string } | null = { id: 'user-123' };
let mockIsMobile = false;
@@ -130,6 +131,11 @@ jest.mock('~/hooks/useCopyToClipboard', () => ({
}),
}));
+jest.mock('@/page-layout/utils/setRecordPageActiveTabId', () => ({
+ setRecordPageActiveTabId: (params: unknown) =>
+ mockSetRecordPageActiveTabId(params),
+}));
+
const renderUseFrontComponentExecutionContext = (
params: Omit<
Parameters[0],
@@ -397,6 +403,63 @@ describe('useFrontComponentExecutionContext', () => {
expect(mockNavigateSidePanel).not.toHaveBeenCalled();
});
+ it('should forward the tab to the side panel record page', async () => {
+ const { result } = renderUseFrontComponentExecutionContext({
+ frontComponentId: FRONT_COMPONENT_ID,
+ });
+
+ await act(async () => {
+ await result.current.frontComponentHostCommunicationApi.openSidePanelPage(
+ {
+ page: SidePanelPages.ViewRecord,
+ recordId: 'lead-1',
+ objectNameSingular: 'lead',
+ tab: 'tab-emails',
+ },
+ );
+ });
+
+ expect(mockOpenRecordInSidePanel).toHaveBeenCalledWith({
+ recordId: 'lead-1',
+ objectNameSingular: 'lead',
+ tab: 'tab-emails',
+ resetNavigationStack: undefined,
+ });
+ });
+
+ it('should set the record page active tab when falling back to full-page navigation', async () => {
+ mockIsMobile = true;
+
+ const { result } = renderUseFrontComponentExecutionContext({
+ frontComponentId: FRONT_COMPONENT_ID,
+ });
+
+ await act(async () => {
+ await result.current.frontComponentHostCommunicationApi.openSidePanelPage(
+ {
+ page: SidePanelPages.ViewRecord,
+ recordId: 'lead-1',
+ objectNameSingular: 'lead',
+ tab: 'tab-emails',
+ },
+ );
+ });
+
+ expect(mockSetRecordPageActiveTabId).toHaveBeenCalledWith({
+ recordId: 'lead-1',
+ objectNameSingular: 'lead',
+ tabId: 'tab-emails',
+ store: expect.anything(),
+ });
+ expect(mockNavigateApp).toHaveBeenCalledWith(
+ AppPath.RecordShowPage,
+ { objectNameSingular: 'lead', objectRecordId: 'lead-1' },
+ undefined,
+ undefined,
+ );
+ expect(mockOpenRecordInSidePanel).not.toHaveBeenCalled();
+ });
+
it('should fall back to full-page navigation on mobile', async () => {
mockIsMobile = true;
diff --git a/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts b/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts
index 9e2cfa626c..7a67906739 100644
--- a/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts
+++ b/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts
@@ -28,6 +28,7 @@ import { useOpenFrontComponentInSidePanel } from '@/side-panel/hooks/useOpenFron
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { useOpenRichTextInSidePanel } from '@/side-panel/hooks/useOpenRichTextInSidePanel';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
+import { setRecordPageActiveTabId } from '@/page-layout/utils/setRecordPageActiveTabId';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -129,9 +130,19 @@ export const useFrontComponentExecutionContext = ({
const openSidePanelPage: FrontComponentHostCommunicationApi['openSidePanelPage'] =
async (params) => {
if (params.page === SidePanelPages.ViewRecord) {
- const { recordId, objectNameSingular, resetNavigationStack } = params;
+ const { recordId, objectNameSingular, tab, resetNavigationStack } =
+ params;
if (isMobile || !canOpenObjectInSidePanel(objectNameSingular)) {
+ if (isDefined(tab)) {
+ setRecordPageActiveTabId({
+ recordId,
+ objectNameSingular,
+ tabId: tab,
+ store,
+ });
+ }
+
await navigate(AppPath.RecordShowPage, {
objectNameSingular,
objectRecordId: recordId,
@@ -143,6 +154,7 @@ export const useFrontComponentExecutionContext = ({
openRecordInSidePanelInternal({
recordId,
objectNameSingular,
+ tab,
resetNavigationStack,
});
diff --git a/packages/twenty-front/src/modules/page-layout/utils/setRecordPageActiveTabId.ts b/packages/twenty-front/src/modules/page-layout/utils/setRecordPageActiveTabId.ts
new file mode 100644
index 0000000000..e9de1adfa4
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/utils/setRecordPageActiveTabId.ts
@@ -0,0 +1,64 @@
+import { type getDefaultStore } from 'jotai';
+import { CoreObjectNameSingular } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
+
+import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
+import { recordPageLayoutByObjectMetadataIdFamilySelector } from '@/page-layout/states/selectors/recordPageLayoutByObjectMetadataIdFamilySelector';
+import { getDefaultRecordPageLayoutId } from '@/page-layout/utils/getDefaultRecordPageLayoutId';
+import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
+import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
+import { PageLayoutType } from '~/generated-metadata/graphql';
+
+export const setRecordPageActiveTabId = ({
+ recordId,
+ objectNameSingular,
+ tabId,
+ store,
+}: {
+ recordId: string;
+ objectNameSingular: string;
+ tabId: string;
+ store: ReturnType;
+}) => {
+ // Dashboards resolve their page layout from record data, not object metadata
+ if (objectNameSingular === CoreObjectNameSingular.Dashboard) {
+ return;
+ }
+
+ const objectMetadataItem = store.get(
+ objectMetadataItemFamilySelector.selectorFamily({
+ objectName: objectNameSingular,
+ objectNameType: 'singular',
+ }),
+ );
+
+ if (!isDefined(objectMetadataItem)) {
+ return;
+ }
+
+ const recordPageLayout = store.get(
+ recordPageLayoutByObjectMetadataIdFamilySelector.selectorFamily({
+ objectMetadataId: objectMetadataItem.id,
+ }),
+ );
+
+ const pageLayoutId = isDefined(recordPageLayout)
+ ? recordPageLayout.id
+ : getDefaultRecordPageLayoutId({
+ targetObjectNameSingular: objectNameSingular,
+ });
+
+ const tabListInstanceId = getTabListInstanceIdFromPageLayoutAndRecord({
+ pageLayoutId,
+ layoutType: PageLayoutType.RECORD_PAGE,
+ targetRecordIdentifier: {
+ id: recordId,
+ targetObjectNameSingular: objectNameSingular,
+ },
+ });
+
+ store.set(
+ activeTabIdComponentState.atomFamily({ instanceId: tabListInstanceId }),
+ tabId,
+ );
+};
diff --git a/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useOpenRecordInSidePanel.test.tsx b/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useOpenRecordInSidePanel.test.tsx
index fa72ce6b92..6af5b33a29 100644
--- a/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useOpenRecordInSidePanel.test.tsx
+++ b/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useOpenRecordInSidePanel.test.tsx
@@ -7,15 +7,19 @@ import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-sto
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
+import { getDefaultRecordPageLayoutId } from '@/page-layout/utils/getDefaultRecordPageLayoutId';
+import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { viewableRecordIdComponentState } from '@/side-panel/pages/record-page/states/viewableRecordIdComponentState';
import { viewableRecordNameSingularComponentState } from '@/side-panel/pages/record-page/states/viewableRecordNameSingularComponentState';
import { sidePanelNavigationMorphItemsByPageState } from '@/side-panel/states/sidePanelNavigationMorphItemsByPageState';
+import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { ContextStorePageType, SidePanelPages } from 'twenty-shared/types';
import { useIcons } from 'twenty-ui/icon';
+import { PageLayoutType } from '~/generated-metadata/graphql';
import { getJestMetadataAndApolloMocksAndCommandMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndCommandMenuWrapper';
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
@@ -206,6 +210,40 @@ describe('useOpenRecordInSidePanel', () => {
});
});
+ it('should preset the record page active tab when a tab is provided', () => {
+ const { result } = renderHooks();
+
+ const recordId = 'record-123';
+ const objectNameSingular = 'person';
+
+ act(() => {
+ result.current.openRecordInSidePanel({
+ recordId,
+ objectNameSingular,
+ tab: 'tab-emails',
+ });
+ });
+
+ const tabListInstanceId = getTabListInstanceIdFromPageLayoutAndRecord({
+ pageLayoutId: getDefaultRecordPageLayoutId({
+ targetObjectNameSingular: objectNameSingular,
+ }),
+ layoutType: PageLayoutType.RECORD_PAGE,
+ targetRecordIdentifier: {
+ id: recordId,
+ targetObjectNameSingular: objectNameSingular,
+ },
+ });
+
+ expect(
+ jotaiStore.get(
+ activeTabIdComponentState.atomFamily({
+ instanceId: tabListInstanceId,
+ }),
+ ),
+ ).toBe('tab-emails');
+ });
+
it('should not open title cell when isNewRecord is false', () => {
const { result } = renderHooks();
diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useOpenRecordInSidePanel.ts b/packages/twenty-front/src/modules/side-panel/hooks/useOpenRecordInSidePanel.ts
index c08e01d185..6806c2e0f2 100644
--- a/packages/twenty-front/src/modules/side-panel/hooks/useOpenRecordInSidePanel.ts
+++ b/packages/twenty-front/src/modules/side-panel/hooks/useOpenRecordInSidePanel.ts
@@ -14,6 +14,7 @@ import { getIconColorForObjectType } from '@/object-metadata/utils/getIconColorF
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { viewableRecordIdState } from '@/object-record/record-side-panel/states/viewableRecordIdState';
import { useOpenNewRecordTitleCell } from '@/object-record/record-title-cell/hooks/useOpenNewRecordTitleCell';
+import { setRecordPageActiveTabId } from '@/page-layout/utils/setRecordPageActiveTabId';
import {
ContextStorePageType,
CoreObjectNameSingular,
@@ -41,14 +42,25 @@ export const useOpenRecordInSidePanel = () => {
({
recordId,
objectNameSingular,
+ tab,
isNewRecord = false,
resetNavigationStack = false,
}: {
recordId: string;
objectNameSingular: string;
+ tab?: string;
isNewRecord?: boolean;
resetNavigationStack?: boolean;
}) => {
+ if (isDefined(tab)) {
+ setRecordPageActiveTabId({
+ recordId,
+ objectNameSingular,
+ tabId: tab,
+ store,
+ });
+ }
+
const navigationStack = store.get(sidePanelNavigationStackState.atom);
const currentNavigationStackItem = navigationStack.at(-1);
diff --git a/packages/twenty-sdk/src/sdk/front-component/globals/frontComponentHostCommunicationApi.ts b/packages/twenty-sdk/src/sdk/front-component/globals/frontComponentHostCommunicationApi.ts
index b765079939..3c9ee49c86 100644
--- a/packages/twenty-sdk/src/sdk/front-component/globals/frontComponentHostCommunicationApi.ts
+++ b/packages/twenty-sdk/src/sdk/front-component/globals/frontComponentHostCommunicationApi.ts
@@ -18,6 +18,7 @@ export type OpenSidePanelPageParams =
page: SidePanelPages.ViewRecord;
recordId: string;
objectNameSingular: string;
+ tab?: string;
resetNavigationStack?: boolean;
}
| {