feat(ai): add ask_questions interactive clarifying-question tool (#22346)

## What & why

Adds an `ask_questions` tool that lets the in-app **Ask AI** assistant
**pause a turn to ask the user one or more multiple-choice questions**
(per the [Figma
design](https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=105959-117153))
and resume once answered — instead of guessing on
ambiguous/consequential decisions.

The tool is **harness-only**: an interactive question UI is meaningless
without a user to answer it, so it must be absent from MCP and from
head-less workflow agents.

## Design — true tool-result resume (not a synthetic user message)

The user's answer is a **structured tool result bound to the
`toolCallId`**, and the **same agent turn resumes** — exactly how
Anthropic (`tool_result` by `tool_use_id`) and OpenAI
(`function_call_output`) model human-in-the-loop.

The naive form of this (leave the tool call in `input-available` to mean
"pending") is **impossible** here: `finalizeDanglingToolParts` rewrites
`input-available` → `output-error` ("Tool execution was interrupted") on
both the persist path (`addMessage`) and the model-reload path
(`chat-execution.service.ts`). That util is a load-bearing safety net,
so weakening it is the wrong move.

Instead:

- `ask_questions` is an **inline, chat-only tool with an `execute` that
returns a `status: 'pending'` result immediately**, so the tool part is
always `output-available` and **immune to `finalizeDanglingToolParts`**.
`stopWhen(hasToolCall('ask_questions'))` halts the turn right after the
call (the model never sees the placeholder).
- A nullable **`thread.pendingQuestionMessageId`** marker records that a
turn is awaiting an answer.
- The new **`answerAgentChatQuestion`** mutation atomically *claims* the
question (clears the marker, marks the thread streaming), **writes the
answer onto the same tool part** (`status: 'answered'`), and
**re-enqueues the turn via the existing `existingTurnId` plumbing**
(`isResume` bypasses the per-turn dedup guard). On resume
`finalizeDanglingToolParts` leaves the `output-available` part untouched
and `convertToModelMessages` emits `assistant(tool_use)` +
`tool_result(answers)`, so the model continues.

This achieves the platform-aligned semantics **without** weakening the
finalize safety net or inventing a fragile new part state.

### Meets the two requirements

- **Survives refresh, scoped per-thread** — the pending state is a
normal persisted `output-available` part + the thread marker; the
frontend card is derived per-thread from the loaded messages, so it
re-appears on reload and only on its own thread.
- **Takes priority over the queue** — a unified `isBlocked =
activeStreamId || pendingQuestionMessageId` gate is applied in both
`sendChatMessage` (new messages queue) and `flushNextQueuedMessage` (the
drain). The queue cannot unpile until the question is answered and the
resumed turn completes.

### Harness-only by construction

`ask_questions` is added **only** to the chat's inline `activeTools`
(like `learn_tools`/`execute_tool`/`load_skills`). It never enters the
tool registry/catalog, so it is invisible to MCP and to workflow agents
— no `MCP_EXCLUDED_TOOL_NAMES` entry needed.

## UX

While a question is pending, the **composer is replaced by the question
card** (matching the Figma): question title + pager (`1/2`), numbered
option rows (`IconSquareNumber*`) with per-option info-icon descriptions
and a "Recommended" badge, and the normal composer as the free-text
fallback ("Type anything to do differently."). The transcript shows a
compact "Asking questions…" status line that becomes an answered
summary.

## Changes

**twenty-shared**
- `ai/types/AskQuestionsToolTypes.ts` —
`AskQuestionItem/Option/Answer/Result`, `ASK_QUESTIONS_TOOL_NAME`.

**twenty-server**
- `ai-chat/tools/ask-questions.tool.ts` — inline tool factory
(pending-result `execute`, zod schema, 1–4 questions × 2–4 options).
- `chat-execution.service.ts` — add to `activeTools` +
`preloadedToolNames`; `hasToolCall` in `stopWhen`.
- `chat-system-prompts.const.ts` — when-to-use guidance.
- `entities/agent-chat-thread.entity.ts` — `pendingQuestionMessageId`
column.
- `stream-agent-chat.job.ts` — set the marker on a question pause;
bypass the dedup guard on resume; suppress the no-text warning for
question pauses.
- `agent-chat-streaming.service.ts` — gate `flushNextQueuedMessage`;
`enqueueResumeStream`.
- `agent-chat.resolver.ts` — gate `sendChatMessage`;
`answerAgentChatQuestion` mutation.
- `agent-chat.service.ts` — `resolvePendingQuestion` (atomic claim +
write answer).
- `dtos/agent-chat-question-answer.input.ts`, `ai.exception.ts`
(`QUESTION_NOT_PENDING`), `utils/find-pending-question-part.util.ts`.

**twenty-front**
- `components/AiChatQuestionCard.tsx` — the interactive card (matches
Figma tokens) + `__stories__/AiChatQuestionCard.stories.tsx`.
- `components/AiChatEditorSection.tsx` — swap the composer for the card
while pending.
- `components/AiChatQuestionStatusRenderer.tsx` + branch in
`AiChatAssistantMessageRenderer.tsx`.
- `states/selectors/agentChatPendingQuestionComponentSelector.ts`,
`types/AgentChatPendingQuestion.ts`.
- `hooks/useSubmitQuestionAnswer.ts` + `utils/markQuestionAnswered.ts`
(optimistic) + `graphql/mutations/answerAgentChatQuestion.ts`.

A design doc lives at
`packages/twenty-server/docs/ASK_USER_QUESTION_TOOL_PLAN.md`.

## Migration

Adds a nullable `pendingQuestionMessageId` (uuid) column to
`core.agentChatThread`. Needs a generated **fast instance command**
(`database:migrate:generate --name addThreadPendingQuestion --type
fast`) — see "Verification status".

## Tests

- Server: `ask-questions.tool.spec.ts` (pending echo + schema bounds),
`find-pending-question-part.util.spec.ts`.
- Front: `markQuestionAnswered.test.ts`, plus the Storybook story.

## Verification status (please read)

This branch was authored in an environment where the monorepo `yarn
install` repeatedly failed on transient TLS resets from the package
registry, so I could **not** locally run the mechanical gates. The logic
was reviewed by hand and the `ai@6.0.97` exports used (`hasToolCall`,
`stepCountIs`, `generateId`) were confirmed against the package's type
defs. Still **TODO** (will rely on CI / a follow-up once deps install):

- [ ] `nx run twenty-shared:generateBarrels` (the `ai/index.ts` export
was added by hand; regen to reconcile)
- [ ] `nx run twenty-front:graphql:generate` (new mutation + input type)
- [ ] generate the fast instance command (migration) for the new column
- [ ] `typecheck` + `lint:diff-with-main` (front + server) — expect
minor import-ordering autofixes
- [ ] run the unit tests

**Screenshots:** reproducing the live flow needs an AI provider API key
(to get the model to actually call `ask_questions`), which isn't
available here. The card can be screenshotted from its **Storybook
story** (`AiChatQuestionCard.stories.tsx`) with no API key — I'll add
that image once deps install, or a reviewer can run `nx storybook
twenty-front`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB

---
_Generated by [Claude
Code](https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22346?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-07-02 15:32:18 +02:00
committed by GitHub
parent 198f1e4916
commit 4aaf171d63
42 changed files with 1911 additions and 72 deletions
@@ -4,13 +4,17 @@ import { RoutingStatusDisplay } from '@/ai/components/RoutingStatusDisplay';
import { ThinkingStepsDisplay } from '@/ai/components/ThinkingStepsDisplay';
import { IconDotsVertical } from 'twenty-ui/icon';
import { AiChatQuestionStatusRenderer } from '@/ai/components/AiChatQuestionStatusRenderer';
import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer';
import { ToolStepRenderer } from '@/ai/components/ToolStepRenderer';
import { groupContiguousThinkingStepParts } from '@/ai/utils/groupContiguousThinkingStepParts';
import { isCodeInterpreterToolPart } from '@/ai/utils/isCodeInterpreterToolPart';
import { styled } from '@linaria/react';
import { isToolUIPart } from 'ai';
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
import { getToolName, isToolUIPart } from 'ai';
import {
ASK_QUESTIONS_TOOL_NAME,
type ExtendedUIMessagePart,
} from 'twenty-shared/ai';
import { useContext } from 'react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
@@ -76,6 +80,15 @@ const MessagePartRenderer = ({
);
default:
if (isToolUIPart(part)) {
if (getToolName(part) === ASK_QUESTIONS_TOOL_NAME) {
return (
<AiChatQuestionStatusRenderer
toolPart={part}
isStreaming={isStreaming}
/>
);
}
return <ToolStepRenderer toolPart={part} isStreaming={isStreaming} />;
}
return null;
@@ -3,8 +3,11 @@ import { EditorContent } from '@tiptap/react';
import { useLingui } from '@lingui/react/macro';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { isDefined } from 'twenty-shared/utils';
import { AiChatBanner } from '@/ai/components/AiChatBanner';
import { AiChatEmptyState } from '@/ai/components/AiChatEmptyState';
import { AiChatQuestionCard } from '@/ai/components/AiChatQuestionCard';
import { AIChatNoMoreBillingCreditsBanner } from '@/ai/components/AIChatNoMoreBillingCreditsBanner';
import { AiChatStandaloneError } from '@/ai/components/AiChatStandaloneError';
import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview';
@@ -18,8 +21,10 @@ import { useAiChatEditor } from '@/ai/hooks/useAiChatEditor';
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { agentChatUserSelectedModelState } from '@/ai/states/agentChatUserSelectedModelState';
import { agentChatPendingQuestionComponentSelector } from '@/ai/states/selectors/agentChatPendingQuestionComponentSelector';
import { Select } from '@/ui/input/components/Select';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { hasReachedCurrentBillingPeriodCapSelector } from '@/workspace/states/hasReachedCurrentBillingPeriodCapSelector';
@@ -137,6 +142,10 @@ export const AiChatEditorSection = () => {
const { editor, handleSendAndClear } = useAiChatEditor();
const pendingQuestion = useAtomComponentSelectorValue(
agentChatPendingQuestionComponentSelector,
);
return (
<>
<AiChatEditorFocusEffect editor={editor} />
@@ -155,35 +164,39 @@ export const AiChatEditorSection = () => {
{hasReachedCurrentBillingPeriodCap && (
<AIChatNoMoreBillingCreditsBanner />
)}
<StyledInputBox>
<StyledEditorWrapper>
<EditorContent editor={editor} />
</StyledEditorWrapper>
<StyledButtonsContainer>
<StyledLeftButtonsContainer>
<AgentChatFileUploadButton />
<AiChatContextUsageButton />
</StyledLeftButtonsContainer>
<StyledRightButtonsContainer>
<Select
dropdownId="ai-chat-smart-model-select"
value={selectedModelId}
onChange={setAgentChatUserSelectedModel}
options={smartModelOptions}
pinnedOption={defaultPinnedOption}
disabled={hasNoEnabledModels}
selectSizeVariant="small"
showContextualTextInControl={false}
withSearchInput
dropdownOffset={{ x: 0, y: 8 }}
/>
<SendMessageButton
onSend={handleSendAndClear}
isDisabled={hasNoEnabledModels}
/>
</StyledRightButtonsContainer>
</StyledButtonsContainer>
</StyledInputBox>
{isDefined(pendingQuestion) ? (
<AiChatQuestionCard pendingQuestion={pendingQuestion} />
) : (
<StyledInputBox>
<StyledEditorWrapper>
<EditorContent editor={editor} />
</StyledEditorWrapper>
<StyledButtonsContainer>
<StyledLeftButtonsContainer>
<AgentChatFileUploadButton />
<AiChatContextUsageButton />
</StyledLeftButtonsContainer>
<StyledRightButtonsContainer>
<Select
dropdownId="ai-chat-smart-model-select"
value={selectedModelId}
onChange={setAgentChatUserSelectedModel}
options={smartModelOptions}
pinnedOption={defaultPinnedOption}
disabled={hasNoEnabledModels}
selectSizeVariant="small"
showContextualTextInControl={false}
withSearchInput
dropdownOffset={{ x: 0, y: 8 }}
/>
<SendMessageButton
onSend={handleSendAndClear}
isDisabled={hasNoEnabledModels}
/>
</StyledRightButtonsContainer>
</StyledButtonsContainer>
</StyledInputBox>
)}
</StyledInputArea>
</>
);
@@ -0,0 +1,481 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { type KeyboardEvent, useContext, useMemo, useState } from 'react';
import { type AskQuestionAnswer, type AskQuestionItem } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { AppTooltip, TooltipDelay } from 'twenty-ui/surfaces';
import {
IconArrowUp,
IconChevronLeft,
IconChevronRightPipe,
IconInfoCircle,
type IconComponent,
IconSquareNumber1,
IconSquareNumber2,
IconSquareNumber3,
IconSquareNumber4,
IconSquareNumber5,
IconSquareNumber6,
IconSquareNumber7,
IconSquareNumber8,
IconSquareNumber9,
} from 'twenty-ui/icon';
import {
LightIconButton,
RoundedIconButton,
type SelectOption,
} from 'twenty-ui/input';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton';
import { AiChatContextUsageButton } from '@/ai/components/internal/AiChatContextUsageButton';
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
import { useSubmitQuestionAnswer } from '@/ai/hooks/useSubmitQuestionAnswer';
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { agentChatUserSelectedModelState } from '@/ai/states/agentChatUserSelectedModelState';
import { type AgentChatPendingQuestion } from '@/ai/types/AgentChatPendingQuestion';
import { Select } from '@/ui/input/components/Select';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
const NUMBER_ICONS: IconComponent[] = [
IconSquareNumber1,
IconSquareNumber2,
IconSquareNumber3,
IconSquareNumber4,
IconSquareNumber5,
IconSquareNumber6,
IconSquareNumber7,
IconSquareNumber8,
IconSquareNumber9,
];
const StyledCard = styled.div`
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
box-sizing: border-box;
display: flex;
flex-direction: column;
width: 100%;
`;
const StyledQuestionSection = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
padding: ${themeCssVariables.spacing[2]};
`;
const StyledQuestionHeaderRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
justify-content: space-between;
min-height: 24px;
padding-left: ${themeCssVariables.spacing[1]};
`;
const StyledQuestionText = styled.p`
color: ${themeCssVariables.font.color.primary};
flex: 1 0 0;
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
line-height: 1.4;
margin: 0;
min-width: 0;
overflow-wrap: anywhere;
`;
const StyledPager = styled.div`
align-items: center;
display: flex;
flex-shrink: 0;
gap: ${themeCssVariables.spacing[1]};
`;
const StyledPagerLabel = styled.span`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.sm};
`;
const StyledOptionsList = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing['0.5']};
`;
const StyledOptionRow = styled.div<{ isHighlighted: boolean }>`
align-items: center;
background: ${({ isHighlighted }) =>
isHighlighted
? themeCssVariables.background.transparent.light
: 'transparent'};
border-radius: ${themeCssVariables.border.radius.sm};
box-sizing: border-box;
cursor: pointer;
display: flex;
gap: ${themeCssVariables.spacing[2]};
height: 32px;
justify-content: space-between;
overflow: hidden;
padding: 0 ${themeCssVariables.spacing[1]};
&:hover {
background: ${themeCssVariables.background.transparent.light};
}
`;
const StyledOptionLeft = styled.div`
align-items: center;
display: flex;
flex: 1 0 0;
gap: ${themeCssVariables.spacing[2]};
min-width: 0;
overflow: hidden;
`;
const StyledOptionLabel = styled.span`
color: ${themeCssVariables.font.color.secondary};
flex-shrink: 0;
font-size: ${themeCssVariables.font.size.md};
line-height: 1.4;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledRecommended = styled.span`
color: ${themeCssVariables.font.color.light};
flex-shrink: 0;
font-size: ${themeCssVariables.font.size.md};
line-height: 1.4;
`;
const StyledDivider = styled.div`
background: ${themeCssVariables.border.color.light};
height: 1px;
width: 100%;
`;
const StyledComposerSection = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
min-height: 80px;
padding: ${themeCssVariables.spacing[2]};
`;
const StyledFreeTextArea = styled.textarea`
background: transparent;
border: none;
color: ${themeCssVariables.font.color.primary};
flex: 1 0 0;
font-family: inherit;
font-size: ${themeCssVariables.font.size.md};
line-height: 1.4;
min-height: 24px;
outline: none;
resize: none;
&::placeholder {
color: ${themeCssVariables.font.color.light};
font-weight: ${themeCssVariables.font.weight.medium};
}
`;
const StyledActionsRow = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
width: 100%;
`;
const StyledLeftActions = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing['0.5']};
`;
const StyledRightActions = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
`;
const areAllQuestionsAnswered = (
questions: AskQuestionItem[],
selectedByQuestion: Record<number, number[]>,
freeTextByQuestion: Record<number, string>,
) =>
questions.every(
(_, index) =>
(selectedByQuestion[index]?.length ?? 0) > 0 ||
(freeTextByQuestion[index] ?? '').trim().length > 0,
);
type AiChatQuestionCardProps = {
pendingQuestion: AgentChatPendingQuestion;
};
export const AiChatQuestionCard = ({
pendingQuestion,
}: AiChatQuestionCardProps) => {
const { t } = useLingui();
const { theme } = useContext(ThemeContext);
const { messageId, toolCallId, questions } = pendingQuestion;
const [currentIndex, setCurrentIndex] = useState(0);
const [selectedByQuestion, setSelectedByQuestion] = useState<
Record<number, number[]>
>({});
const [freeTextByQuestion, setFreeTextByQuestion] = useState<
Record<number, string>
>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const { submitAnswer } = useSubmitQuestionAnswer();
const { options: modelOptions, pinnedOption } = useAiModelOptions({
variant: 'pinned-default',
});
const { enabledModels } = useWorkspaceAiModelAvailability();
const hasNoEnabledModels = enabledModels.length === 0;
const { selectedModelId } = useAgentChatModelId();
const setAgentChatUserSelectedModel = useSetAtomState(
agentChatUserSelectedModelState,
);
const defaultPinnedOption: SelectOption<string | null> | undefined =
pinnedOption ? { ...pinnedOption, value: null } : undefined;
const currentQuestion = questions[currentIndex];
const hasMultipleQuestions = questions.length > 1;
const isLastQuestion = currentIndex === questions.length - 1;
const buildAnswers = (
selected: Record<number, number[]>,
): AskQuestionAnswer[] =>
questions.map((_, index) => {
const trimmedFreeText = (freeTextByQuestion[index] ?? '').trim();
return {
questionIndex: index,
selectedOptionIndices: selected[index] ?? [],
freeText: trimmedFreeText.length > 0 ? trimmedFreeText : undefined,
};
});
const submit = async (answers: AskQuestionAnswer[]) => {
if (isSubmitting) {
return;
}
setIsSubmitting(true);
await submitAnswer({ messageId, toolCallId, answers });
setIsSubmitting(false);
};
const handleSelectOption = (optionIndex: number) => {
if (currentQuestion.allowMultiSelect === true) {
setSelectedByQuestion((previous) => {
const current = previous[currentIndex] ?? [];
const next = current.includes(optionIndex)
? current.filter((value) => value !== optionIndex)
: [...current, optionIndex];
return { ...previous, [currentIndex]: next };
});
return;
}
const nextSelected = {
...selectedByQuestion,
[currentIndex]: [optionIndex],
};
setSelectedByQuestion(nextSelected);
if (!isLastQuestion) {
setCurrentIndex(currentIndex + 1);
return;
}
if (areAllQuestionsAnswered(questions, nextSelected, freeTextByQuestion)) {
void submit(buildAnswers(nextSelected));
}
};
const allQuestionsAnswered = useMemo(
() =>
areAllQuestionsAnswered(
questions,
selectedByQuestion,
freeTextByQuestion,
),
[questions, selectedByQuestion, freeTextByQuestion],
);
const handleSend = () => {
if (!allQuestionsAnswered) {
return;
}
void submit(buildAnswers(selectedByQuestion));
};
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSend();
}
};
return (
<StyledCard>
<StyledQuestionSection>
<StyledQuestionHeaderRow>
<StyledQuestionText>{currentQuestion.question}</StyledQuestionText>
{hasMultipleQuestions && (
<StyledPager>
<LightIconButton
Icon={IconChevronLeft}
size="small"
disabled={currentIndex === 0}
onClick={() =>
setCurrentIndex((index) => Math.max(0, index - 1))
}
/>
<StyledPagerLabel>
{currentIndex + 1}/{questions.length}
</StyledPagerLabel>
<LightIconButton
Icon={IconChevronRightPipe}
size="small"
disabled={isLastQuestion}
onClick={() =>
setCurrentIndex((index) =>
Math.min(questions.length - 1, index + 1),
)
}
/>
</StyledPager>
)}
</StyledQuestionHeaderRow>
<StyledOptionsList>
{currentQuestion.options.map((option, optionIndex) => {
const NumberIcon =
NUMBER_ICONS[optionIndex] ??
NUMBER_ICONS[NUMBER_ICONS.length - 1];
const isSelected = (
selectedByQuestion[currentIndex] ?? []
).includes(optionIndex);
const hasSelection =
(selectedByQuestion[currentIndex] ?? []).length > 0;
const isHighlighted =
isSelected || (!hasSelection && option.isRecommended === true);
const tooltipId = `ask-question-option-${toolCallId}-${currentIndex}-${optionIndex}`;
return (
<StyledOptionRow
key={optionIndex}
isHighlighted={isHighlighted}
role="button"
tabIndex={0}
onClick={() => handleSelectOption(optionIndex)}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleSelectOption(optionIndex);
}
}}
>
<StyledOptionLeft>
<NumberIcon
size={theme.icon.size.sm}
color={themeCssVariables.font.color.tertiary}
/>
<StyledOptionLabel>{option.label}</StyledOptionLabel>
{option.isRecommended === true && (
<StyledRecommended>· {t`Recommended`}</StyledRecommended>
)}
</StyledOptionLeft>
{isDefined(option.description) && (
<>
<span
id={tooltipId}
onClick={(event) => event.stopPropagation()}
>
<LightIconButton
Icon={IconInfoCircle}
size="small"
accent="tertiary"
/>
</span>
<AppTooltip
anchorSelect={`#${tooltipId}`}
content={option.description}
delay={TooltipDelay.shortDelay}
place="left"
/>
</>
)}
</StyledOptionRow>
);
})}
</StyledOptionsList>
</StyledQuestionSection>
<StyledDivider />
<StyledComposerSection>
<StyledFreeTextArea
value={freeTextByQuestion[currentIndex] ?? ''}
placeholder={t`Type anything to do differently.`}
onChange={(event) =>
setFreeTextByQuestion((previous) => ({
...previous,
[currentIndex]: event.target.value,
}))
}
onKeyDown={handleKeyDown}
autoFocus
/>
<StyledActionsRow>
<StyledLeftActions>
<AgentChatFileUploadButton />
<AiChatContextUsageButton />
</StyledLeftActions>
<StyledRightActions>
<Select
dropdownId="ai-chat-question-model-select"
value={selectedModelId}
onChange={setAgentChatUserSelectedModel}
options={modelOptions}
pinnedOption={defaultPinnedOption}
disabled={hasNoEnabledModels}
selectSizeVariant="small"
showContextualTextInControl={false}
withSearchInput
dropdownOffset={{ x: 0, y: 8 }}
/>
<RoundedIconButton
Icon={IconArrowUp}
size="medium"
onClick={handleSend}
disabled={!allQuestionsAnswered || isSubmitting}
/>
</StyledRightActions>
</StyledActionsRow>
</StyledComposerSection>
</StyledCard>
);
};
@@ -0,0 +1,112 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { type DynamicToolUIPart, type ToolUIPart } from 'ai';
import { useContext } from 'react';
import { type AskQuestionsToolResult } from 'twenty-shared/ai';
import { IconHelpCircle } from 'twenty-ui/icon';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { ShimmeringText } from '@/ai/components/ShimmeringText';
const StyledContainer = styled.div`
align-items: flex-start;
color: ${themeCssVariables.font.color.tertiary};
display: flex;
gap: ${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[1]} 0;
svg {
flex-shrink: 0;
margin-top: 1px;
}
`;
const StyledContent = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing['0.5']};
min-width: 0;
`;
const StyledMessage = styled.span`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledAnswerLine = styled.span`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
`;
const StyledAnswerValue = styled.span`
color: ${themeCssVariables.font.color.secondary};
`;
export const AiChatQuestionStatusRenderer = ({
toolPart,
isStreaming,
}: {
toolPart: ToolUIPart | DynamicToolUIPart;
isStreaming: boolean;
}) => {
const { t } = useLingui();
const { theme } = useContext(ThemeContext);
const result = (toolPart.output as { result?: AskQuestionsToolResult } | null)
?.result;
const questions = result?.questions ?? [];
const status = result?.status ?? 'pending';
if (status === 'pending') {
const label = t`Asking questions...`;
return (
<StyledContainer>
<IconHelpCircle size={theme.icon.size.sm} />
{isStreaming ? (
<ShimmeringText>
<StyledMessage>{label}</StyledMessage>
</ShimmeringText>
) : (
<StyledMessage>{label}</StyledMessage>
)}
</StyledContainer>
);
}
const answers = result?.answers ?? [];
return (
<StyledContainer>
<IconHelpCircle size={theme.icon.size.sm} />
<StyledContent>
<StyledMessage>{t`Questions answered`}</StyledMessage>
{questions.map((question, index) => {
const answer = answers.find(
(candidate) => candidate.questionIndex === index,
);
const selectedLabels = (answer?.selectedOptionIndices ?? [])
.map((optionIndex) => question.options[optionIndex]?.label)
.filter(isNonEmptyString);
const freeTextAnswer = answer?.freeText ?? '';
const value =
freeTextAnswer.length > 0
? freeTextAnswer
: selectedLabels.join(', ');
if (value.length === 0) {
return null;
}
return (
<StyledAnswerLine key={index}>
{question.header}: <StyledAnswerValue>{value}</StyledAnswerValue>
</StyledAnswerLine>
);
})}
</StyledContent>
</StyledContainer>
);
};
@@ -0,0 +1,124 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { useStore } from 'jotai';
import { type ReactNode, useEffect } from 'react';
import { ComponentDecorator } from 'twenty-ui/testing';
import { AiChatQuestionCard } from '@/ai/components/AiChatQuestionCard';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { type AgentChatPendingQuestion } from '@/ai/types/AgentChatPendingQuestion';
import { styled } from '@linaria/react';
import { RootDecorator } from '~/testing/decorators/RootDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
const StyledContainer = styled.div`
max-width: 400px;
padding: 24px;
`;
const INSTANCE_ID = 'agentChatQuestionCardStory';
const singleQuestion: AgentChatPendingQuestion = {
messageId: 'assistant-1',
toolCallId: 'call-1',
questions: [
{
header: 'Email type',
question: 'What type of emails would you like to send?',
options: [
{
label: 'A welcome email',
description: 'A short, friendly note to introduce yourself.',
isRecommended: true,
},
{ label: 'A presentation of Twenty' },
{ label: 'An offer for a potential partnership' },
],
},
],
};
const multipleQuestions: AgentChatPendingQuestion = {
messageId: 'assistant-1',
toolCallId: 'call-2',
questions: [
singleQuestion.questions[0],
{
header: 'Tone',
question: 'Which tone should the email use?',
options: [
{ label: 'Friendly', isRecommended: true },
{ label: 'Formal' },
],
},
],
};
const longQuestion: AgentChatPendingQuestion = {
messageId: 'assistant-1',
toolCallId: 'call-3',
questions: [
{
header: 'Improvement',
question:
'What is the one improvement you would make to the "Send follow-up emails to stale opportunities" workflow before we roll it out to the whole team?',
options: [
{ label: 'Wording clarity' },
{ label: 'Better layout' },
{ label: 'More flexibility', isRecommended: true },
{ label: 'Fewer steps' },
],
},
multipleQuestions.questions[1],
],
};
const StoreSeeder = ({ children }: { children: ReactNode }) => {
const store = useStore();
useEffect(() => {
store.set(currentAiChatThreadState.atom, 'thread-1');
store.set(agentChatDisplayedThreadState.atom, 'thread-1');
}, [store]);
return <>{children}</>;
};
const meta: Meta<typeof AiChatQuestionCard> = {
title: 'Modules/AiChat/AiChatQuestionCard',
component: AiChatQuestionCard,
decorators: [
(Story) => (
<AgentChatComponentInstanceContext.Provider
value={{ instanceId: INSTANCE_ID }}
>
<StoreSeeder>
<StyledContainer>
<Story />
</StyledContainer>
</StoreSeeder>
</AgentChatComponentInstanceContext.Provider>
),
SnackBarDecorator,
ComponentDecorator,
RootDecorator,
],
};
export default meta;
type Story = StoryObj<typeof AiChatQuestionCard>;
export const SingleQuestion: Story = {
args: { pendingQuestion: singleQuestion },
};
export const MultipleQuestions: Story = {
args: { pendingQuestion: multipleQuestions },
};
export const LongQuestion: Story = {
args: { pendingQuestion: longQuestion },
};
@@ -0,0 +1,21 @@
import { gql } from '@apollo/client';
export const ANSWER_AGENT_CHAT_QUESTION = gql`
mutation AnswerAgentChatQuestion(
$threadId: UUID!
$messageId: UUID!
$answers: [AgentChatQuestionAnswerInput!]!
$modelId: String
) {
answerAgentChatQuestion(
threadId: $threadId
messageId: $messageId
answers: $answers
modelId: $modelId
) {
messageId
queued
streamId
}
}
`;
@@ -0,0 +1,81 @@
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useApolloClient } from '@apollo/client/react';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { type AskQuestionAnswer } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { AGENT_CHAT_INSTANCE_ID } from '@/ai/constants/AgentChatInstanceId';
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
import { ANSWER_AGENT_CHAT_QUESTION } from '@/ai/graphql/mutations/answerAgentChatQuestion';
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { markQuestionAnswered } from '@/ai/utils/markQuestionAnswered';
import { markQuestionPending } from '@/ai/utils/markQuestionPending';
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
export const useSubmitQuestionAnswer = () => {
const apolloClient = useApolloClient();
const store = useStore();
const { enqueueErrorSnackBar } = useSnackBar();
const { modelIdForRequest } = useAgentChatModelId();
const submitAnswer = useCallback(
async ({
messageId,
toolCallId,
answers,
}: {
messageId: string;
toolCallId: string;
answers: AskQuestionAnswer[];
}) => {
const threadId = store.get(agentChatDisplayedThreadState.atom);
if (!isDefined(threadId)) {
return;
}
const messagesAtom = agentChatMessagesComponentFamilyState.atomFamily({
instanceId: AGENT_CHAT_INSTANCE_ID,
familyKey: { threadId },
});
const previousMessages = store.get(messagesAtom);
store.set(
messagesAtom,
markQuestionAnswered(previousMessages, messageId, toolCallId, answers),
);
try {
await apolloClient.mutate({
mutation: ANSWER_AGENT_CHAT_QUESTION,
variables: {
threadId,
messageId,
answers,
modelId: modelIdForRequest,
},
});
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
} catch (error) {
const currentMessages = store.get(messagesAtom);
store.set(
messagesAtom,
markQuestionPending(currentMessages, messageId, toolCallId),
);
enqueueErrorSnackBar({
apolloError: CombinedGraphQLErrors.is(error) ? error : undefined,
});
}
},
[apolloClient, store, enqueueErrorSnackBar, modelIdForRequest],
);
return { submitAnswer };
};
@@ -0,0 +1,57 @@
import { getToolName, isToolUIPart } from 'ai';
import {
ASK_QUESTIONS_TOOL_NAME,
type AskQuestionsToolResult,
} from 'twenty-shared/ai';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { type AgentChatPendingQuestion } from '@/ai/types/AgentChatPendingQuestion';
import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
export const agentChatPendingQuestionComponentSelector =
createAtomComponentSelector<AgentChatPendingQuestion | null>({
key: 'agentChatPendingQuestionComponentSelector',
componentInstanceContext: AgentChatComponentInstanceContext,
get:
({ instanceId }) =>
({ get }) => {
const currentThreadId = get(agentChatDisplayedThreadState);
const messages = get(agentChatMessagesComponentFamilyState, {
instanceId,
familyKey: { threadId: currentThreadId },
});
const lastAssistantMessage = [...messages]
.reverse()
.find((message) => message.role === 'assistant');
if (!lastAssistantMessage) {
return null;
}
for (const part of lastAssistantMessage.parts) {
if (
!isToolUIPart(part) ||
getToolName(part) !== ASK_QUESTIONS_TOOL_NAME
) {
continue;
}
const result = (part.output as { result?: AskQuestionsToolResult })
?.result;
if (result?.status === 'pending') {
return {
messageId: lastAssistantMessage.id,
toolCallId: part.toolCallId,
questions: result.questions,
};
}
}
return null;
},
});
@@ -0,0 +1,7 @@
import { type AskQuestionItem } from 'twenty-shared/ai';
export type AgentChatPendingQuestion = {
messageId: string;
toolCallId: string;
questions: AskQuestionItem[];
};
@@ -0,0 +1,83 @@
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { markQuestionAnswered } from '@/ai/utils/markQuestionAnswered';
const buildMessages = (status: 'pending' | 'answered'): ExtendedUIMessage[] => [
{
id: 'assistant-1',
role: 'assistant',
parts: [
{
type: 'tool-ask_questions',
toolCallId: 'call-1',
state: 'output-available',
input: { questions: [] },
output: {
success: true,
message: 'x',
result: {
questions: [
{ header: 'Type', question: 'Which?', options: [{ label: 'A' }] },
],
status,
},
},
},
],
metadata: { createdAt: '2024-01-01T00:00:00.000Z' },
} as unknown as ExtendedUIMessage,
];
describe('markQuestionAnswered', () => {
it('flips the matching tool part to answered and stores the answers', () => {
const answers = [
{ questionIndex: 0, selectedOptionIndices: [0], freeText: undefined },
];
const result = markQuestionAnswered(
buildMessages('pending'),
'assistant-1',
'call-1',
answers,
);
const output = (result[0].parts[0] as { output?: { result?: unknown } })
.output;
expect(output).toMatchObject({
result: { status: 'answered', answers },
});
});
it('preserves the original questions on the resolved part', () => {
const result = markQuestionAnswered(
buildMessages('pending'),
'assistant-1',
'call-1',
[{ questionIndex: 0, selectedOptionIndices: [0] }],
);
const output = (
result[0].parts[0] as {
output?: { result?: { questions?: unknown[] } };
}
).output;
expect(output?.result?.questions).toHaveLength(1);
});
it('leaves non-matching tool calls untouched', () => {
const result = markQuestionAnswered(
buildMessages('pending'),
'assistant-1',
'other-call',
[{ questionIndex: 0, selectedOptionIndices: [0] }],
);
const output = (
result[0].parts[0] as { output?: { result?: { status?: string } } }
).output;
expect(output?.result?.status).toBe('pending');
});
});
@@ -0,0 +1,48 @@
import { isToolUIPart } from 'ai';
import {
type AskQuestionAnswer,
type AskQuestionsToolResult,
type ExtendedUIMessage,
type ExtendedUIMessagePart,
} from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
export const markQuestionAnswered = (
messages: ExtendedUIMessage[],
messageId: string,
toolCallId: string,
answers: AskQuestionAnswer[],
): ExtendedUIMessage[] =>
messages.map((message) => {
if (message.id !== messageId) {
return message;
}
return {
...message,
parts: message.parts.map((part) => {
if (!isToolUIPart(part) || part.toolCallId !== toolCallId) {
return part;
}
const previousOutput = isDefined(part.output)
? (part.output as Record<string, unknown>)
: {};
const previousResult = previousOutput.result as
| AskQuestionsToolResult
| undefined;
return {
...part,
output: {
...previousOutput,
result: {
questions: previousResult?.questions ?? [],
status: 'answered',
answers,
} satisfies AskQuestionsToolResult,
},
} as ExtendedUIMessagePart;
}),
};
});
@@ -0,0 +1,45 @@
import { isToolUIPart } from 'ai';
import {
type AskQuestionsToolResult,
type ExtendedUIMessage,
type ExtendedUIMessagePart,
} from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
export const markQuestionPending = (
messages: ExtendedUIMessage[],
messageId: string,
toolCallId: string,
): ExtendedUIMessage[] =>
messages.map((message) => {
if (message.id !== messageId) {
return message;
}
return {
...message,
parts: message.parts.map((part) => {
if (!isToolUIPart(part) || part.toolCallId !== toolCallId) {
return part;
}
const previousOutput = isDefined(part.output)
? (part.output as Record<string, unknown>)
: {};
const previousResult = previousOutput.result as
| AskQuestionsToolResult
| undefined;
return {
...part,
output: {
...previousOutput,
result: {
questions: previousResult?.questions ?? [],
status: 'pending',
} satisfies AskQuestionsToolResult,
},
} as ExtendedUIMessagePart;
}),
};
});