Fix AI demo workspace skill (#18575)

This PR fixes what allows to have a working demo workspace skill.

- Skill updated many times into something that works
- Fixed infinite loop in AI chat by memoizing ai-sdk output
- Finished navigateToView implementation
- Increased MAX_STEPS to 300 so the chat don't quit in the middle of a
long running skill
- Added CreateManyRelationFields
This commit is contained in:
Lucas Bordeau
2026-03-12 13:19:01 +01:00
committed by GitHub
parent db5b4d9c6c
commit cb3e32df86
12 changed files with 262 additions and 54 deletions
@@ -24,6 +24,7 @@ import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages'
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useCallback, useMemo } from 'react';
import {
type GetChatThreadsQuery,
GetChatThreadsDocument,
@@ -181,7 +182,11 @@ export const useAgentChatData = () => {
},
});
const isNewThread = currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
const isNewThread = useMemo(
() => currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
[currentAIChatThread],
);
const { loading: messagesLoading, data } = useGetChatMessagesQuery({
variables: { threadId: currentAIChatThread! },
skip: !isDefined(currentAIChatThread) || isNewThread,
@@ -191,7 +196,7 @@ export const useAgentChatData = () => {
},
});
const ensureThreadForDraft = () => {
const ensureThreadForDraft = useCallback(() => {
const current = store.get(currentAIChatThreadState.atom);
if (current !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
return;
@@ -218,9 +223,16 @@ export const useAgentChatData = () => {
threadIdPromise.finally(() => {
setPendingCreateFromDraftPromise(null);
});
};
}, [
createChatThread,
setPendingCreateFromDraftPromise,
store,
setIsCreatingChatThread,
]);
const ensureThreadIdForSend = async (): Promise<string | null> => {
const ensureThreadIdForSend = useCallback(async (): Promise<
string | null
> => {
const current = store.get(currentAIChatThreadState.atom);
if (current !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
return current;
@@ -247,16 +259,33 @@ export const useAgentChatData = () => {
} finally {
setIsCreatingChatThread(false);
}
};
}, [createChatThread, store, setIsCreatingChatThread]);
const uiMessages = mapDBMessagesToUIMessages(data?.chatMessages || []);
const isLoading = messagesLoading || threadsLoading;
const threadsLoadingMemoized = useMemo(
() => threadsLoading,
[threadsLoading],
);
const messagesLoadingMemoized = useMemo(
() => messagesLoading,
[messagesLoading],
);
const uiMessages = useMemo(
() => mapDBMessagesToUIMessages(data?.chatMessages || []),
[data?.chatMessages],
);
const isLoading = useMemo(
() => messagesLoadingMemoized || threadsLoadingMemoized,
[messagesLoadingMemoized, threadsLoadingMemoized],
);
return {
uiMessages,
isLoading,
threadsLoading,
messagesLoading,
threadsLoading: threadsLoadingMemoized,
messagesLoading: messagesLoadingMemoized,
ensureThreadForDraft,
ensureThreadIdForSend,
};
@@ -2,6 +2,7 @@ import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId'
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { scrollWrapperScrollBottomComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollBottomComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useCallback, useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
const SCROLL_BOTTOM_THRESHOLD_PX = 10;
@@ -16,9 +17,12 @@ export const useAgentChatScrollToBottom = () => {
AI_CHAT_SCROLL_WRAPPER_ID,
);
const isNearBottom = scrollWrapperScrollBottom <= SCROLL_BOTTOM_THRESHOLD_PX;
const isNearBottom = useMemo(
() => scrollWrapperScrollBottom <= SCROLL_BOTTOM_THRESHOLD_PX,
[scrollWrapperScrollBottom],
);
const scrollToBottom = () => {
const scrollToBottom = useCallback(() => {
const { scrollWrapperElement } = getScrollWrapperElement();
if (!isDefined(scrollWrapperElement)) {
return;
@@ -27,7 +31,7 @@ export const useAgentChatScrollToBottom = () => {
scrollWrapperElement.scrollTo({
top: scrollWrapperElement.scrollHeight,
});
};
}, [getScrollWrapperElement]);
return { scrollToBottom, isNearBottom };
};
@@ -80,9 +80,26 @@ export const useProcessUIToolCallMessage = () => {
break;
}
case 'navigateToView':
// TODO: implement
case 'navigateToView': {
const viewObjectNamePlural = objectMetadataItems.find(
(item) =>
item.nameSingular === navigateAppOutput.objectNameSingular,
)?.namePlural;
if (!isDefined(viewObjectNamePlural)) {
throw new Error(
`Object with singular name ${navigateAppOutput.objectNameSingular} not found, cannot navigate to view from chat.`,
);
}
navigateApp(
AppPath.RecordIndexPage,
{ objectNamePlural: viewObjectNamePlural },
{ viewId: navigateAppOutput.viewId },
);
break;
}
case 'wait': {
await sleep(navigateAppOutput.durationMs);
break;
@@ -1,6 +1,6 @@
import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/ObjectRecordOperationBrowserEventName';
import { type ObjectRecordOperation } from '@/object-record/types/ObjectRecordOperation';
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
import { type ObjectRecordOperation } from '@/object-record/types/ObjectRecordOperation';
import { useEffect } from 'react';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
@@ -15,7 +15,7 @@ import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/h
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useStore } from 'jotai';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { computeRecordGqlOperationFilter } from 'twenty-shared/utils';
export const RecordTableEmptyHasNewRecordEffect = () => {
@@ -79,20 +79,28 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
operationSignature,
});
const handleObjectRecordOperation = (
objectRecordOperationEventDetail: ObjectRecordOperationBrowserEventDetail,
) => {
const objectRecordOperation = objectRecordOperationEventDetail.operation;
const handleObjectRecordOperation = useCallback(
(
objectRecordOperationEventDetail: ObjectRecordOperationBrowserEventDetail,
) => {
const objectRecordOperation = objectRecordOperationEventDetail.operation;
if (
objectRecordOperation.type.includes('update') ||
objectRecordOperation.type.includes('create')
) {
if (!isRecordTableInitialLoading && !recordTableHasRecords) {
store.set(recordTableWentFromEmptyToNotEmptyCallbackState, true);
if (
objectRecordOperation.type.includes('update') ||
objectRecordOperation.type.includes('create')
) {
if (!isRecordTableInitialLoading && !recordTableHasRecords) {
store.set(recordTableWentFromEmptyToNotEmptyCallbackState, true);
}
}
}
};
},
[
recordTableHasRecords,
isRecordTableInitialLoading,
store,
recordTableWentFromEmptyToNotEmptyCallbackState,
],
);
useListenToObjectRecordOperationBrowserEvent({
onObjectRecordOperationBrowserEvent: handleObjectRecordOperation,
@@ -1,13 +1,14 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { groupObjectRecordSseEventsByObjectMetadataItemNameSingular } from '@/sse-db-event/utils/groupObjectRecordSseEventsByObjectMetadataItemNameSingular';
import { turnSseObjectRecordEventsToObjectRecordOperationBrowserEvents } from '@/sse-db-event/utils/turnSseObjectRecordEventToObjectRecordOperationBrowserEvent';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type ObjectRecordEventWithQueryIds } from '~/generated-metadata/graphql';
export const useDispatchObjectRecordEventsFromSseToBrowserEvents = () => {
const { objectMetadataItems } = useObjectMetadataItems();
const store = useStore();
const dispatchObjectRecordEventsFromSseToBrowserEvents = useCallback(
(objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[]) => {
@@ -24,6 +25,8 @@ export const useDispatchObjectRecordEventsFromSseToBrowserEvents = () => {
objectRecordEventsByObjectMetadataItemNameSingular.keys(),
);
const objectMetadataItems = store.get(objectMetadataItemsState.atom);
for (const objectMetadataItemNameSingular of objectMetadataItemNamesSingular) {
const objectRecordEventsForThisObjectMetadataItem =
objectRecordEventsByObjectMetadataItemNameSingular.get(
@@ -49,7 +52,7 @@ export const useDispatchObjectRecordEventsFromSseToBrowserEvents = () => {
}
}
},
[objectMetadataItems],
[store],
);
return { dispatchObjectRecordEventsFromSseToBrowserEvents };
@@ -14,11 +14,11 @@ import { captureException } from '@sentry/react';
import { isNonEmptyString } from '@sniptt/guards';
import { print, type ExecutionResult } from 'graphql';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type EventSubscription } from '~/generated-metadata/graphql';
import { useStore } from 'jotai';
export const useTriggerEventStreamCreation = () => {
const store = useStore();
@@ -79,7 +79,7 @@ export const useTriggerEventStreamCreation = () => {
onEventSubscription: EventSubscription;
}>,
) => {
if (isDefined(value?.errors)) {
if (isDefined(value?.errors) && Array.isArray(value.errors)) {
captureException(
new Error(`SSE subscription error: ${value.errors[0]?.message}`),
);