Files
twenty/packages/twenty-front/src/modules/ai/components/AiChatQuestionCard.tsx
T
Félix Malfait 4aaf171d63 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. -->
2026-07-02 15:32:18 +02:00

482 lines
14 KiB
TypeScript

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>
);
};