FN-6501: render chat question response controls

Render structured assistant question prompts directly in chat surfaces.

- Add parser and formatter support for question tool calls.
- Add reusable question response UI for select, multi-select, text, and confirm prompts.
- Wire regular chat and quick chat to show live and answered question states.
- Cover parsing and chat response behavior with dashboard tests and localized labels.

Files changed:
 .changeset/fn-6501-chat-question-response.md       |   5 +
 docs/dashboard-guide.md                            |   2 +
 .../app/components/ChatQuestionResponse.css        | 194 ++++++++++++++++
 .../app/components/ChatQuestionResponse.tsx        | 247 +++++++++++++++++++++
 packages/dashboard/app/components/ChatView.tsx     |  85 ++++++-
 packages/dashboard/app/components/QuickChatFAB.tsx |  84 ++++++-
 .../__tests__/ChatQuestionResponse.test.tsx        |  52 +++++
 .../app/components/__tests__/ChatView.test.tsx     |  51 +++++
 .../app/components/__tests__/QuickChatFAB.test.tsx |  64 ++++++
 .../utils/__tests__/parseQuestionToolCall.test.ts  |  82 +++++++
 .../dashboard/app/utils/parseQuestionToolCall.ts   | 240 ++++++++++++++++++++
 packages/i18n/locales/en/app.json                  |  10 +
 12 files changed, 1105 insertions(+), 11 deletions(-)

Fusion-Task-Id: FN-6501
Fusion-Task-Lineage: 1fd4a3aa-b2d7-4157-a196-852fdeda680d
This commit is contained in:
gsxdsm
2026-06-16 20:13:13 -07:00
parent 2ec4eb3518
commit a4537167ab
12 changed files with 1105 additions and 11 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Render assistant question tool calls as shared in-chat response cards in full Chat and Quick Chat.

View File

@@ -237,6 +237,7 @@ Chat view provides project-scoped conversations with agents.
- On mobile direct-chat threads, tapping the active title/identity in the thread header opens a lightweight conversation dropdown so you can switch to another direct session without backing out to the sidebar list first; long conversation titles now stay readable in the dropdown via wrapped option text and taller touch-friendly rows.
- On mobile (`max-width: 768px`), chat bubbles are slightly wider in full Chat for improved readability while preserving header/composer gutters.
- Full Chat tool-call summaries now use a denser mobile layout: grouped and single-call collapsed rows keep icon + label + status on one line (Quick Chat-style scanability) while expanded details remain unchanged.
- Assistant question tool calls now render as a shared in-chat response card instead of a generic tool-call disclosure. The card supports select, multi-select, text, and yes/no prompts, sends the formatted answer back into the same direct or room thread, and renders historical answered questions read-only.
- The desktop Chat view toggle and mobile Chat tab now show an unread-response indicator when a live assistant reply arrives for your active chat thread after you leave Chat; opening Chat clears it immediately.
- Agent-backed chat sessions now expose the same mailbox messaging tools (`fn_send_message`, `fn_read_messages`) used by runtime execution/heartbeat flows whenever the engine `MessageStore` is available; model-only chats continue to run without mailbox tools.
@@ -286,6 +287,7 @@ Quick Chat is an optional floating panel for fast, project-scoped assistant conv
- Queued follow-up messages entered while a Quick Chat response is still streaming now persist per session, so closing/reopening the panel restores the queued text and flushes it once the active response completes.
- Resume lookups still use targeted session queries instead of loading the full active-session list first
- Tool-call summaries in the floating quick-chat panel are intentionally condensed into a single-line header row (especially on small screens) so tool name + status stay scannable without multi-line wrapping
- Question tool calls use the same shared response card as full Chat, with compact spacing in the floating panel and read-only answered history so Quick Chat can continue agent clarification loops without exposing raw tool JSON.
- On mobile viewports, opening Quick Chat auto-focuses the composer as soon as it is ready so the keyboard opens immediately
- FAB dragging uses pointer events with document-level move/up tracking and a 5px drag threshold so Android touch drags reposition reliably while short taps still open Quick Chat
- Quick Chat now mirrors full Chat tail behavior: if you scroll up, live updates stop auto-following and a **Latest** jump control appears until you jump back down.

View File

@@ -0,0 +1,194 @@
.chat-question-response {
display: flex;
flex-direction: column;
gap: var(--space-md);
margin-block: var(--space-sm);
padding: var(--space-md);
border: thin solid color-mix(in srgb, var(--accent) 28%, var(--border));
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--accent) 6%, var(--bg-secondary));
color: var(--text);
}
.chat-question-response--compact {
gap: var(--space-sm);
padding: var(--space-sm);
border-radius: var(--radius-md);
}
.chat-question-response__header,
.chat-question-response__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.chat-question-response__eyebrow,
.chat-question-response__answered-label,
.chat-question-response__submitted-label {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.chat-question-response__answered-label {
color: var(--color-success);
}
.chat-question-response__questions {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.chat-question-response__question {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.chat-question-response__question-header {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
}
.chat-question-response__question-text {
margin: 0;
font-size: 1rem;
line-height: 1.3;
color: var(--text);
}
.chat-question-response--compact .chat-question-response__question-text {
font-size: 0.875rem;
}
.chat-question-response__description,
.chat-question-response__hint {
margin: 0;
font-size: 0.875rem;
line-height: 1.45;
color: var(--text-muted);
}
.chat-question-response__options,
.chat-question-response__confirm-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin-block-start: var(--space-xs);
}
.chat-question-response__confirm-group {
flex-direction: row;
flex-wrap: wrap;
}
.chat-question-response__option {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
padding: var(--space-sm);
border: thin solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
cursor: pointer;
transition: border-color var(--transition-fast), background-color var(--transition-fast), color var(--transition-fast);
}
.chat-question-response__option:hover,
.chat-question-response__option--selected,
.chat-question-response__confirm--selected {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--card));
}
.chat-question-response__option input {
margin: calc(var(--space-xs) / 2) 0 0;
accent-color: var(--accent);
}
.chat-question-response__option-content {
display: flex;
flex-direction: column;
gap: calc(var(--space-xs) / 2);
min-width: 0;
}
.chat-question-response__option-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
}
.chat-question-response__option-description {
font-size: 0.875rem;
line-height: 1.45;
color: var(--text-muted);
}
.chat-question-response__textarea {
width: 100%;
min-height: calc(var(--space-xl) * 3);
margin-block-start: var(--space-xs);
resize: vertical;
line-height: 1.45;
}
.chat-question-response__submit {
flex: 0 0 auto;
}
.chat-question-response__submitted {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-sm);
border: thin solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
}
.chat-question-response__submitted pre {
margin: 0;
white-space: pre-wrap;
font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.45;
color: var(--text);
}
.chat-question-response--compact .chat-question-response__actions {
align-items: stretch;
flex-direction: column;
}
.chat-question-response--compact .chat-question-response__submit {
width: 100%;
}
@media (max-width: 768px) {
.chat-question-response {
padding: var(--space-sm);
border-radius: var(--radius-md);
}
.chat-question-response__header,
.chat-question-response__actions {
align-items: stretch;
flex-direction: column;
}
.chat-question-response__confirm-group {
flex-direction: column;
}
.chat-question-response__submit {
width: 100%;
}
}

View File

@@ -0,0 +1,247 @@
import "./ChatQuestionResponse.css";
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { useTranslation } from "react-i18next";
import type { ChatQuestion, ChatQuestionAnswers, ChatQuestionAnswerValue, ParsedQuestionToolCall } from "../utils/parseQuestionToolCall";
import { formatQuestionAnswer } from "../utils/parseQuestionToolCall";
export interface ChatQuestionResponseProps {
parsed: ParsedQuestionToolCall;
answered?: boolean;
submittedAnswer?: string;
compact?: boolean;
disabled?: boolean;
onSubmit: (answerText: string, structured: Record<string, unknown>) => void;
}
/**
* FNXC:ChatQuestionResponse 2026-06-16-19:25:
* In-chat question tools need an attractive shared answer affordance for single-select, multi-select, free-text, and confirm prompts.
* Historical or already-answered messages must render read-only so old assistant questions do not keep duplicate live input boxes in regular chat or quick chat.
*/
export function ChatQuestionResponse({
parsed,
answered = false,
submittedAnswer,
compact = false,
disabled = false,
onSubmit,
}: ChatQuestionResponseProps) {
const { t } = useTranslation("app");
const [answers, setAnswers] = useState<ChatQuestionAnswers>({});
const textareaRefs = useRef(new Map<string, HTMLTextAreaElement>());
const isValid = useMemo(
() => parsed.questions.every((question) => isQuestionAnswerValid(question, answers[question.id])),
[answers, parsed.questions],
);
useLayoutEffect(() => {
for (const textarea of textareaRefs.current.values()) {
textarea.style.height = "0";
textarea.style.height = `${textarea.scrollHeight}px`;
}
}, [answers]);
const setQuestionAnswer = useCallback((questionId: string, value: ChatQuestionAnswerValue) => {
setAnswers((current) => ({ ...current, [questionId]: value }));
}, []);
const toggleMultiSelect = useCallback((questionId: string, optionId: string, checked: boolean) => {
setAnswers((current) => {
const currentValue = current[questionId];
const selected = Array.isArray(currentValue) ? currentValue : [];
return {
...current,
[questionId]: checked ? [...selected, optionId] : selected.filter((id) => id !== optionId),
};
});
}, []);
const handleSubmit = useCallback(() => {
if (!isValid || answered || disabled) {
return;
}
const answerText = formatQuestionAnswer(parsed.questions, answers);
onSubmit(answerText, answers);
}, [answers, answered, disabled, isValid, onSubmit, parsed.questions]);
return (
<section
className={`chat-question-response${compact ? " chat-question-response--compact" : ""}${answered ? " chat-question-response--answered" : ""}`}
data-testid="chat-question-response"
aria-label={t("chat.questionResponseLabel", "Question from assistant")}
>
<div className="chat-question-response__header">
<span className="chat-question-response__eyebrow">{t("chat.questionResponseEyebrow", "Assistant question")}</span>
{answered && <span className="chat-question-response__answered-label">{t("chat.questionAnsweredLabel", "Answered")}</span>}
</div>
<div className="chat-question-response__questions">
{parsed.questions.map((question, questionIndex) => (
<article className="chat-question-response__question" key={question.id}>
{question.header && <p className="chat-question-response__question-header">{question.header}</p>}
<h4 className="chat-question-response__question-text">{question.question}</h4>
{question.description && <p className="chat-question-response__description">{question.description}</p>}
{answered ? null : (
<QuestionControls
question={question}
questionIndex={questionIndex}
value={answers[question.id]}
disabled={disabled}
setQuestionAnswer={setQuestionAnswer}
toggleMultiSelect={toggleMultiSelect}
textareaRefs={textareaRefs}
/>
)}
</article>
))}
</div>
{answered ? (
<div className="chat-question-response__submitted" data-testid="chat-question-response-submitted-answer">
<span className="chat-question-response__submitted-label">{t("chat.questionSubmittedAnswerLabel", "Submitted answer")}</span>
<pre>{submittedAnswer || t("chat.questionAnsweredWithoutContent", "A later user reply answered this question.")}</pre>
</div>
) : (
<div className="chat-question-response__actions">
<p className="chat-question-response__hint">{t("chat.questionSelectHint", "Answer all questions to continue the chat.")}</p>
<button
type="button"
className="btn btn-primary chat-question-response__submit"
data-testid="chat-question-response-submit"
disabled={!isValid || disabled}
onClick={handleSubmit}
>
{t("chat.questionSubmit", "Send answer")}
</button>
</div>
)}
</section>
);
}
interface QuestionControlsProps {
question: ChatQuestion;
questionIndex: number;
value: ChatQuestionAnswerValue | undefined;
disabled: boolean;
setQuestionAnswer: (questionId: string, value: ChatQuestionAnswerValue) => void;
toggleMultiSelect: (questionId: string, optionId: string, checked: boolean) => void;
textareaRefs: MutableRefObject<Map<string, HTMLTextAreaElement>>;
}
function QuestionControls({
question,
questionIndex,
value,
disabled,
setQuestionAnswer,
toggleMultiSelect,
textareaRefs,
}: QuestionControlsProps) {
const { t } = useTranslation("app");
if (question.type === "text") {
return (
<textarea
className="input chat-question-response__textarea"
data-testid={`chat-question-response-text-${question.id}`}
placeholder={t("chat.questionTextPlaceholder", "Type your answer here…")}
value={typeof value === "string" ? value : ""}
disabled={disabled}
rows={3}
ref={(element) => {
if (element) {
textareaRefs.current.set(question.id, element);
} else {
textareaRefs.current.delete(question.id);
}
}}
onChange={(event) => setQuestionAnswer(question.id, event.target.value)}
/>
);
}
if (question.type === "confirm") {
return (
<div className="chat-question-response__confirm-group" role="group" aria-label={question.question}>
<button
type="button"
className={`btn chat-question-response__confirm${value === true ? " chat-question-response__confirm--selected" : ""}`}
data-testid={`chat-question-response-option-${question.id}-yes`}
disabled={disabled}
onClick={() => setQuestionAnswer(question.id, true)}
>
{t("chat.questionConfirmYes", "Yes")}
</button>
<button
type="button"
className={`btn chat-question-response__confirm${value === false ? " chat-question-response__confirm--selected" : ""}`}
data-testid={`chat-question-response-option-${question.id}-no`}
disabled={disabled}
onClick={() => setQuestionAnswer(question.id, false)}
>
{t("chat.questionConfirmNo", "No")}
</button>
</div>
);
}
const options = question.options ?? [];
const selectedValues = Array.isArray(value) ? value : [];
const radioName = `chat-question-${question.id}-${questionIndex}`;
const isMulti = question.type === "multi_select";
return (
<div className="chat-question-response__options" role={isMulti ? "group" : "radiogroup"} aria-label={question.question}>
{options.map((option) => {
const checked = isMulti ? selectedValues.includes(option.id) : value === option.id;
return (
<label
key={option.id}
className={`chat-question-response__option${checked ? " chat-question-response__option--selected" : ""}`}
data-testid={`chat-question-response-option-${question.id}-${option.id}`}
>
<input
type={isMulti ? "checkbox" : "radio"}
name={isMulti ? undefined : radioName}
value={option.id}
checked={checked}
disabled={disabled}
onChange={(event) => {
if (isMulti) {
toggleMultiSelect(question.id, option.id, event.target.checked);
} else {
setQuestionAnswer(question.id, option.id);
}
}}
/>
<span className="chat-question-response__option-content">
<span className="chat-question-response__option-label">{option.label}</span>
{option.description && <span className="chat-question-response__option-description">{option.description}</span>}
</span>
</label>
);
})}
</div>
);
}
function isQuestionAnswerValid(question: ChatQuestion, value: ChatQuestionAnswerValue | undefined): boolean {
if (question.type === "text") {
return typeof value === "string" && value.trim().length > 0;
}
if (question.type === "multi_select") {
return Array.isArray(value) && value.length > 0;
}
if (question.type === "confirm") {
return typeof value === "boolean";
}
return typeof value === "string" && value.trim().length > 0;
}

View File

@@ -32,6 +32,7 @@ import { useViewportMode } from "./Header";
import { updateGlobalSettings, type DiscoveredSkill } from "../api";
import type { Agent } from "@fusion/core";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ChatQuestionResponse } from "./ChatQuestionResponse";
import { ProviderIcon } from "./ProviderIcon";
import { AgentMentionPopup } from "./AgentMentionPopup";
import { AgentAvatar } from "./AgentAvatar";
@@ -48,6 +49,7 @@ import { matchesAgentMentionFilter } from "./mentionMatching";
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { parseQuestionToolCall } from "../utils/parseQuestionToolCall";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
@@ -258,10 +260,33 @@ function renderFailureReference(reference: FailureInfo["reference"], t: (key: st
);
}
function renderToolCalls(toolCalls: ToolCallInfo[] | undefined, t: (key: string, defaultValue: string, opts?: Record<string, unknown>) => string): ReactNode {
function renderToolCalls(
toolCalls: ToolCallInfo[] | undefined,
t: (key: string, defaultValue: string, opts?: Record<string, unknown>) => string,
options?: {
isAwaitingAnswer?: boolean;
submittedAnswer?: string;
onQuestionSubmit?: (answerText: string, structured: Record<string, unknown>) => void;
},
): ReactNode {
if (!toolCalls || toolCalls.length === 0) return null;
const renderToolCallItem = (toolCall: ToolCallInfo, index: number) => {
const parsedQuestion = parseQuestionToolCall(toolCall);
if (parsedQuestion) {
const isAwaitingAnswer = options?.isAwaitingAnswer === true;
return (
<ChatQuestionResponse
key={`${toolCall.toolName}-${index}`}
parsed={parsedQuestion}
answered={!isAwaitingAnswer}
submittedAnswer={options?.submittedAnswer}
disabled={!isAwaitingAnswer}
onSubmit={(answerText, structured) => options?.onQuestionSubmit?.(answerText, structured)}
/>
);
}
const isRunning = toolCall.status === "running";
const isError = toolCall.status === "completed" && toolCall.isError;
const argsSummary = formatToolArgsSummary(toolCall.args);
@@ -742,6 +767,13 @@ interface ChatMessageItemProps {
roomContext: RoomContext | null;
copyAction?: ReactNode;
onScrollToTop?: (messageId: string) => void;
isAwaitingQuestionAnswer: boolean;
submittedQuestionAnswer?: string;
onQuestionSubmit: (answerText: string, structured: Record<string, unknown>) => void;
}
function findSubmittedQuestionAnswer(messages: ChatMessageInfo[], messageIndex: number): string | undefined {
return messages.slice(messageIndex + 1).find((message) => message.role === "user")?.content;
}
// Renders a single chat message bubble. Memoized so the streaming bubble's
@@ -760,6 +792,9 @@ const ChatMessageItem = memo(function ChatMessageItem({
roomContext,
copyAction,
onScrollToTop,
isAwaitingQuestionAnswer,
submittedQuestionAnswer,
onQuestionSubmit,
}: ChatMessageItemProps) {
const { t } = useTranslation("app");
const isAssistantMessage = message.role === "assistant";
@@ -923,7 +958,11 @@ const ChatMessageItem = memo(function ChatMessageItem({
)}
</div>
)}
{renderToolCalls(message.toolCalls, t)}
{renderToolCalls(message.toolCalls, t, {
isAwaitingAnswer: isAwaitingQuestionAnswer,
submittedAnswer: submittedQuestionAnswer,
onQuestionSubmit,
})}
{message.thinkingOutput && (
<details className="chat-message-thinking">
<summary>{t("chat.thinking", "Thinking")}</summary>
@@ -2033,6 +2072,31 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
handleSend();
}, [messageInput, pendingAttachments, chatRoomsEnabled, chatScope, rooms, rooms.clearRoom, clearComposerState, addToast, handleSend]);
const handleQuestionSubmit = useCallback(async (answerText: string) => {
if (chatRoomsEnabled && chatScope === "rooms") {
if (!rooms.activeRoom) {
return;
}
try {
await rooms.sendRoomMessage(answerText);
} catch (error) {
const message = error instanceof Error && error.message.trim()
? error.message
: t("chat.failedToSendRoomMessage", "Failed to send room message");
addToast(message, "error");
}
return;
}
if (!activeSession) {
return;
}
sendMessage(answerText);
}, [activeSession, addToast, chatRoomsEnabled, chatScope, rooms, sendMessage, t]);
const handleSkillSelect = useCallback(
(skill: DiscoveredSkill) => {
setMessageInput((currentInput) => {
@@ -2748,7 +2812,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
</div>
{isStreaming ? (
<>
{messages.map((message) => (
{messages.map((message, index) => (
<ChatMessageItem
key={message.id}
message={message}
@@ -2763,6 +2827,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
roomContext={null}
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
onScrollToTop={handleScrollMessageToTop}
isAwaitingQuestionAnswer={message.role === "assistant" && index === messages.length - 1 && !isStreaming}
submittedQuestionAnswer={findSubmittedQuestionAnswer(messages, index)}
onQuestionSubmit={handleQuestionSubmit}
/>
))}
<div className="chat-message chat-message--assistant chat-message--streaming">
@@ -2781,7 +2848,10 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
</div>
)}
{showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")}
{renderToolCalls(streamingToolCalls, t)}
{renderToolCalls(streamingToolCalls, t, {
isAwaitingAnswer: true,
onQuestionSubmit: handleQuestionSubmit,
})}
{streamingThinking && (
<details className="chat-message-thinking">
<summary>{t("chat.thinking", "Thinking")}</summary>
@@ -2803,7 +2873,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
<div className="chat-empty-state">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
) : (
<>
{messages.map((message) => (
{messages.map((message, index) => (
<ChatMessageItem
key={message.id}
message={message}
@@ -2818,6 +2888,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
roomContext={null}
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
onScrollToTop={handleScrollMessageToTop}
isAwaitingQuestionAnswer={message.role === "assistant" && index === messages.length - 1 && !isStreaming}
submittedQuestionAnswer={findSubmittedQuestionAnswer(messages, index)}
onQuestionSubmit={handleQuestionSubmit}
/>
))}
</>
@@ -3454,6 +3527,8 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
mentionAgentsByName={mentionAgentsByName}
roomContext={roomContext}
onScrollToTop={handleScrollMessageToTop}
isAwaitingQuestionAnswer={false}
onQuestionSubmit={handleQuestionSubmit}
/>
);
})

View File

@@ -19,6 +19,7 @@ import { ChevronDown, Eye, EyeOff, Hash, MessageSquare, Paperclip, Plus, Send, S
import { attachmentBaseUrlForRoom, type Agent, type ModelInfo } from "../api";
import type { DiscoveredSkill } from "@fusion/dashboard";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ChatQuestionResponse } from "./ChatQuestionResponse";
import { ProviderIcon } from "./ProviderIcon";
import { AgentMentionPopup } from "./AgentMentionPopup";
import { matchesAgentMentionFilter } from "./mentionMatching";
@@ -36,6 +37,7 @@ import { useChatRooms } from "../hooks/useChatRooms";
import { useChatUnread } from "../hooks/useChatUnread";
import { getPersistedLastQuickChatSessionId } from "../hooks/quickChatLastSessionStorage";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
import { parseQuestionToolCall } from "../utils/parseQuestionToolCall";
interface PendingAttachment {
file: File;
@@ -148,10 +150,35 @@ function formatToolResultSummary(result: unknown): string | null {
}
}
function renderToolCalls(toolCalls: ToolCallInfo[] | undefined, compact: boolean, t: TFunction<"app">): ReactNode {
function renderToolCalls(
toolCalls: ToolCallInfo[] | undefined,
compact: boolean,
t: TFunction<"app">,
options?: {
isAwaitingAnswer?: boolean;
submittedAnswer?: string;
onQuestionSubmit?: (answerText: string, structured: Record<string, unknown>) => void;
},
): ReactNode {
if (!toolCalls || toolCalls.length === 0) return null;
const renderToolCallItem = (toolCall: ToolCallInfo, index: number) => {
const parsedQuestion = parseQuestionToolCall(toolCall);
if (parsedQuestion) {
const isAwaitingAnswer = options?.isAwaitingAnswer === true;
return (
<ChatQuestionResponse
key={`${toolCall.toolName}-${index}`}
parsed={parsedQuestion}
compact={compact}
answered={!isAwaitingAnswer}
submittedAnswer={options?.submittedAnswer}
disabled={!isAwaitingAnswer}
onSubmit={(answerText, structured) => options?.onQuestionSubmit?.(answerText, structured)}
/>
);
}
const isRunning = toolCall.status === "running";
const isError = toolCall.status === "completed" && toolCall.isError;
const argsSummary = formatToolArgsSummary(toolCall.args);
@@ -795,10 +822,17 @@ interface QuickChatMessageItemProps {
roomContext: QuickChatRoomContext | null;
projectId?: string;
onToggleRender: (id: string) => void;
isAwaitingQuestionAnswer: boolean;
submittedQuestionAnswer?: string;
onQuestionSubmit: (answerText: string, structured: Record<string, unknown>) => void;
}
// Memoized so streaming state churn doesn't re-render every prior message
// (each one would re-run ReactMarkdown over its full content otherwise).
function findSubmittedQuestionAnswer(messages: ChatMessageInfo[], messageIndex: number): string | undefined {
return messages.slice(messageIndex + 1).find((message) => message.role === "user")?.content;
}
const QuickChatMessageItem = memo(function QuickChatMessageItem({
message,
forcePlain,
@@ -806,6 +840,9 @@ const QuickChatMessageItem = memo(function QuickChatMessageItem({
roomContext,
projectId,
onToggleRender,
isAwaitingQuestionAnswer,
submittedQuestionAnswer,
onQuestionSubmit,
}: QuickChatMessageItemProps) {
const { t } = useTranslation("app");
const isSent = message.role === "user";
@@ -907,7 +944,11 @@ const QuickChatMessageItem = memo(function QuickChatMessageItem({
</>
)}
{renderedAttachments}
{renderToolCalls(message.toolCalls, true, t)}
{renderToolCalls(message.toolCalls, true, t, {
isAwaitingAnswer: isAwaitingQuestionAnswer,
submittedAnswer: submittedQuestionAnswer,
onQuestionSubmit,
})}
</div>
);
});
@@ -2122,6 +2163,25 @@ export function QuickChatFAB({
stopStreaming,
]);
const handleQuestionSubmit = useCallback(async (answerText: string) => {
try {
setHelpMessageVisible(false);
if (chatRoomsEnabled && roomsState.activeRoom) {
await roomsState.sendRoomMessage(answerText);
} else {
await sendMessage(answerText);
}
} catch (error) {
const message = error instanceof Error && error.message.trim()
? error.message
: (chatRoomsEnabled && roomsState.activeRoom ? t("chat.sendRoomMessageFailed", "Failed to send room message") : t("chat.sendMessageFailed", "Failed to send message"));
addToast(message, "error");
} finally {
focusComposerInput();
preserveComposerFocusRef.current = false;
}
}, [addToast, chatRoomsEnabled, focusComposerInput, roomsState, sendMessage, t]);
const handleAttachmentDragEnter = useCallback((event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
dragDepthRef.current += 1;
@@ -2907,7 +2967,7 @@ export function QuickChatFAB({
<div className="quick-chat-panel-empty">{t("chat.loadingConversation", "Loading conversation…")}</div>
) : !roomThreadActive && isStreaming ? (
<>
{displayedMessages.map((message: ChatMessageInfo) => (
{displayedMessages.map((message: ChatMessageInfo, index) => (
<QuickChatMessageItem
key={message.id}
message={message}
@@ -2916,6 +2976,9 @@ export function QuickChatFAB({
roomContext={roomContext}
projectId={projectId}
onToggleRender={toggleMessageRenderMode}
isAwaitingQuestionAnswer={message.role === "assistant" && index === displayedMessages.length - 1 && !isStreaming}
submittedQuestionAnswer={findSubmittedQuestionAnswer(displayedMessages, index)}
onQuestionSubmit={handleQuestionSubmit}
/>
))}
{helpMessageVisible && (
@@ -2947,7 +3010,10 @@ export function QuickChatFAB({
{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.connectingStatus", "Connecting…")}
</p>
)}
{renderToolCalls(streamingToolCalls, true, t)}
{renderToolCalls(streamingToolCalls, true, t, {
isAwaitingAnswer: true,
onQuestionSubmit: handleQuestionSubmit,
})}
{streamingThinking && (
<details className="chat-message-thinking" data-testid="quick-chat-streaming-thinking">
<summary>{t("chat.thinkingLabel", "Thinking")}</summary>
@@ -2962,7 +3028,7 @@ export function QuickChatFAB({
<div className="quick-chat-panel-empty">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
) : (
<>
{displayedMessages.map((message: ChatMessageInfo) => (
{displayedMessages.map((message: ChatMessageInfo, index) => (
<QuickChatMessageItem
key={message.id}
message={message}
@@ -2971,6 +3037,9 @@ export function QuickChatFAB({
roomContext={roomContext}
projectId={projectId}
onToggleRender={toggleMessageRenderMode}
isAwaitingQuestionAnswer={message.role === "assistant" && index === displayedMessages.length - 1 && !isStreaming}
submittedQuestionAnswer={findSubmittedQuestionAnswer(displayedMessages, index)}
onQuestionSubmit={handleQuestionSubmit}
/>
))}
{helpMessageVisible && (
@@ -2985,7 +3054,7 @@ export function QuickChatFAB({
<div className="quick-chat-panel-empty">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
) : (
<>
{displayedMessages.map((message: ChatMessageInfo) => (
{displayedMessages.map((message: ChatMessageInfo, index) => (
<QuickChatMessageItem
key={message.id}
message={message}
@@ -2994,6 +3063,9 @@ export function QuickChatFAB({
roomContext={roomContext}
projectId={projectId}
onToggleRender={toggleMessageRenderMode}
isAwaitingQuestionAnswer={message.role === "assistant" && index === displayedMessages.length - 1 && !isStreaming}
submittedQuestionAnswer={findSubmittedQuestionAnswer(displayedMessages, index)}
onQuestionSubmit={handleQuestionSubmit}
/>
))}
{helpMessageVisible && (

View File

@@ -0,0 +1,52 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ChatQuestionResponse } from "../ChatQuestionResponse";
import type { ParsedQuestionToolCall } from "../../utils/parseQuestionToolCall";
const parsed: ParsedQuestionToolCall = {
questions: [
{ id: "single", type: "single_select", question: "Pick one", options: [{ id: "a", label: "Alpha" }, { id: "b", label: "Beta", description: "Second" }] },
{ id: "multi", type: "multi_select", question: "Pick many", options: [{ id: "x", label: "X" }, { id: "y", label: "Y" }] },
{ id: "text", type: "text", question: "Explain" },
{ id: "confirm", type: "confirm", question: "Proceed?" },
],
};
describe("ChatQuestionResponse", () => {
it("renders all question controls and validates before submit", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ChatQuestionResponse parsed={parsed} onSubmit={onSubmit} />);
expect(screen.getByTestId("chat-question-response")).toBeInTheDocument();
const submit = screen.getByTestId("chat-question-response-submit");
expect(submit).toBeDisabled();
await user.click(screen.getByTestId("chat-question-response-option-single-a"));
await user.click(screen.getByTestId("chat-question-response-option-multi-x"));
await user.type(screen.getByTestId("chat-question-response-text-text"), "Need the safe path");
await user.click(screen.getByTestId("chat-question-response-option-confirm-yes"));
expect(submit).toBeEnabled();
await user.click(submit);
expect(onSubmit).toHaveBeenCalledWith(
"> Q: Pick one\nAlpha\n\n> Q: Pick many\nX\n\n> Q: Explain\nNeed the safe path\n\n> Q: Proceed?\nYes",
{ single: "a", multi: ["x"], text: "Need the safe path", confirm: true },
);
});
it("renders an answered read-only summary", () => {
render(<ChatQuestionResponse parsed={parsed} answered submittedAnswer="> Q: Pick one\nAlpha" onSubmit={vi.fn()} />);
expect(screen.getByText("Answered")).toBeInTheDocument();
expect(screen.getByTestId("chat-question-response-submitted-answer")).toHaveTextContent("Alpha");
expect(screen.queryByTestId("chat-question-response-submit")).not.toBeInTheDocument();
});
it("supports compact mode", () => {
render(<ChatQuestionResponse parsed={{ questions: [parsed.questions[0]!] }} compact onSubmit={vi.fn()} />);
expect(screen.getByTestId("chat-question-response")).toHaveClass("chat-question-response--compact");
});
});

View File

@@ -1039,6 +1039,57 @@ describe("ChatView", () => {
expect(details?.querySelector(".chat-tool-call-status-text")).toHaveTextContent("completed");
});
it("renders latest question tool calls as inline response UI and sends answers", async () => {
const sendMessage = vi.fn();
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Question Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
sendMessage,
messages: [
{
id: "msg-001",
sessionId: "session-001",
role: "assistant",
content: "Need input",
toolCalls: [{ toolName: "ask_user", args: { question: "Pick?", options: ["Alpha", "Beta"] }, isError: false, status: "completed" }],
createdAt: "2026-04-08T00:00:00.000Z",
},
],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-question-response")).toBeInTheDocument();
expect(document.querySelector(".chat-tool-call")).not.toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-question-response-option-q-0-opt-0"));
await userEvent.click(screen.getByTestId("chat-question-response-submit"));
expect(sendMessage).toHaveBeenCalledWith("> Q: Pick?\nAlpha");
});
it("renders historical question tool calls read-only with submitted answer", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Question Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-001",
sessionId: "session-001",
role: "assistant",
content: "Need input",
toolCalls: [{ toolName: "ask_user", args: { question: "Pick?", options: ["Alpha", "Beta"] }, isError: false, status: "completed" }],
createdAt: "2026-04-08T00:00:00.000Z",
},
{ id: "msg-002", sessionId: "session-001", role: "user", content: "> Q: Pick?\nBeta", createdAt: "2026-04-08T00:01:00.000Z" },
],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-question-response")).toHaveTextContent("Answered");
expect(screen.getByTestId("chat-question-response-submitted-answer")).toHaveTextContent("Beta");
expect(screen.queryByTestId("chat-question-response-submit")).not.toBeInTheDocument();
});
it("truncates tool names when more than 5 unique", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },

View File

@@ -180,6 +180,70 @@ describe("QuickChatFAB session-first UX", () => {
]);
});
it("renders compact question tool calls and sends answers through quick chat", async () => {
mockFetchChatMessages.mockResolvedValue({
messages: [{
id: "msg-question",
sessionId: "session-model",
role: "assistant",
content: "Need input",
metadata: { toolCalls: [{ toolName: "ask_user", args: { question: "Pick?", options: ["Alpha", "Beta"] }, isError: false, status: "completed" }] },
createdAt: "2026-05-16T00:00:00.000Z",
}],
});
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
expect(await screen.findByTestId("chat-question-response")).toHaveClass("chat-question-response--compact");
expect(document.querySelector(".chat-tool-call")).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId("chat-question-response-option-q-0-opt-0"));
fireEvent.click(screen.getByTestId("chat-question-response-submit"));
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"session-model",
"> Q: Pick?\nAlpha",
expect.any(Object),
undefined,
"proj-1",
);
});
});
it("keeps non-question quick chat tool calls generic and historical questions read-only", async () => {
mockFetchChatMessages.mockResolvedValue({
messages: [
{
id: "msg-tool",
sessionId: "session-model",
role: "assistant",
content: "Read file",
metadata: { toolCalls: [{ toolName: "read", args: { path: "foo.ts" }, isError: false, status: "completed" }] },
createdAt: "2026-05-16T00:00:02.000Z",
},
{ id: "msg-user", sessionId: "session-model", role: "user", content: "> Q: Pick?\nBeta", createdAt: "2026-05-16T00:00:01.000Z" },
{
id: "msg-question",
sessionId: "session-model",
role: "assistant",
content: "Need input",
metadata: { toolCalls: [{ toolName: "ask_user", args: { question: "Pick?", options: ["Alpha", "Beta"] }, isError: false, status: "completed" }] },
createdAt: "2026-05-16T00:00:00.000Z",
},
],
});
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
expect(await screen.findByTestId("chat-question-response")).toHaveTextContent("Answered");
expect(screen.getByTestId("chat-question-response-submitted-answer")).toHaveTextContent("Beta");
expect(screen.queryByTestId("chat-question-response-submit")).not.toBeInTheDocument();
expect(screen.getByText("read")).toBeInTheDocument();
});
it("removes header mode toggle and renders session dropdown", async () => {
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import type { ToolCallInfo } from "../../hooks/chatTypes";
import { formatQuestionAnswer, isQuestionToolName, parseQuestionToolCall } from "../parseQuestionToolCall";
function toolCall(toolName: string, args?: Record<string, unknown>): ToolCallInfo {
return { toolName, args, isError: false, status: "completed" };
}
describe("parseQuestionToolCall", () => {
it("recognizes question tool names case-insensitively", () => {
expect(isQuestionToolName("AskUserQuestion")).toBe(true);
expect(isQuestionToolName("ASK_USER")).toBe(true);
expect(isQuestionToolName("grep")).toBe(false);
});
it("normalizes Claude AskUserQuestion multi-question args", () => {
const parsed = parseQuestionToolCall(toolCall("AskUserQuestion", {
questions: [
{ question: "Pick one", header: "Decision", options: [{ label: "A" }, { label: "B", description: "Bee" }] },
{ id: "features", question: "Pick many", options: [{ id: "x", label: "X" }, { label: "Y" }], multiSelect: true },
],
}));
expect(parsed).toEqual({
questions: [
{
id: "q-0",
type: "single_select",
question: "Pick one",
header: "Decision",
description: undefined,
options: [{ id: "opt-0", label: "A", description: undefined }, { id: "opt-1", label: "B", description: "Bee" }],
multiSelect: undefined,
},
{
id: "features",
type: "multi_select",
question: "Pick many",
header: undefined,
description: undefined,
options: [{ id: "x", label: "X", description: undefined }, { id: "opt-1", label: "Y", description: undefined }],
multiSelect: true,
},
],
});
});
it.each([
["ask_user", { question: "Continue?", options: ["Yes", "No"] }, "confirm"],
["request_user_input", { prompt: "Name?" }, "text"],
["elicit", { message: "Choose", choices: [{ value: "a", label: "Alpha" }] }, "single_select"],
["ask_followup_question", { question: "Boolean?", type: "boolean" }, "confirm"],
] as const)("normalizes %s common schema", (name, args, expectedType) => {
const parsed = parseQuestionToolCall(toolCall(name, args));
expect(parsed?.questions).toHaveLength(1);
expect(parsed?.questions[0]?.type).toBe(expectedType);
expect(parsed?.questions[0]?.id).toBe("q-0");
});
it("falls back for malformed, empty option select, and non-question tools", () => {
expect(parseQuestionToolCall(toolCall("ask_user"))).toBeNull();
expect(parseQuestionToolCall(toolCall("ask_user", { question: "" }))).toBeNull();
expect(parseQuestionToolCall(toolCall("ask_user", { question: "Pick", type: "single_select", options: [] }))).toBeNull();
expect(parseQuestionToolCall(toolCall("read", { question: "No" }))).toBeNull();
});
it("formats selected labels, text, and confirm answers", () => {
const parsed = parseQuestionToolCall(toolCall("AskUserQuestion", {
questions: [
{ id: "one", question: "Pick one", options: [{ id: "a", label: "Alpha" }] },
{ id: "many", question: "Pick many", options: [{ id: "x", label: "X" }, { id: "y", label: "Y" }], multiSelect: true },
{ id: "text", question: "Explain" },
{ id: "ok", question: "Proceed?", type: "confirm" },
],
}));
expect(parsed).not.toBeNull();
expect(formatQuestionAnswer(parsed!.questions, { one: "a", many: ["x", "y"], text: "Because", ok: false })).toBe(
"> Q: Pick one\nAlpha\n\n> Q: Pick many\nX, Y\n\n> Q: Explain\nBecause\n\n> Q: Proceed?\nNo",
);
});
});

View File

@@ -0,0 +1,240 @@
import type { PlanningQuestionType } from "@fusion/core";
import type { ToolCallInfo } from "../hooks/chatTypes";
export const QUESTION_TOOL_NAMES = [
"AskUserQuestion",
"ask_user",
"ask_followup_question",
"request_user_input",
"elicit",
"ask_question",
] as const;
const QUESTION_TOOL_NAME_SET = new Set(QUESTION_TOOL_NAMES.map((name) => name.toLowerCase()));
export interface ChatQuestionOption {
id: string;
label: string;
description?: string;
}
export interface ChatQuestion {
id: string;
type: PlanningQuestionType;
question: string;
header?: string;
description?: string;
options?: ChatQuestionOption[];
multiSelect?: boolean;
}
export interface ParsedQuestionToolCall {
questions: ChatQuestion[];
}
export type ChatQuestionAnswerValue = string | string[] | boolean;
export type ChatQuestionAnswers = Record<string, ChatQuestionAnswerValue>;
/**
* FNXC:ChatQuestionResponse 2026-06-16-19:18:
* Chat question tools from multiple agent CLIs must render as structured response controls in both ChatView and QuickChatFAB instead of exposing raw JSON in generic tool-call details.
* Keep schema normalization centralized so both chat surfaces recognize the same question tools, synthesize stable ids, and fall back safely when args are malformed.
*/
export function isQuestionToolName(name: string): boolean {
return QUESTION_TOOL_NAME_SET.has(name.toLowerCase());
}
export function parseQuestionToolCall(toolCall: ToolCallInfo): ParsedQuestionToolCall | null {
if (!isQuestionToolName(toolCall.toolName)) {
return null;
}
const args = asRecord(toolCall.args);
if (!args) {
return null;
}
const rawQuestions = Array.isArray(args.questions) ? args.questions : null;
const questions = rawQuestions
? rawQuestions.map((rawQuestion, index) => normalizeQuestion(rawQuestion, index)).filter(isChatQuestion)
: [normalizeQuestion(args, 0)].filter(isChatQuestion);
return questions.length > 0 ? { questions } : null;
}
export function formatQuestionAnswer(questions: ChatQuestion[], answers: ChatQuestionAnswers): string {
return questions
.map((question) => {
const answer = answers[question.id];
return `> Q: ${question.question}\n${formatAnswerValue(question, answer)}`;
})
.join("\n\n");
}
function normalizeQuestion(rawValue: unknown, index: number): ChatQuestion | null {
const raw = asRecord(rawValue);
if (!raw) {
return null;
}
const questionText = firstString(raw.question, raw.prompt, raw.message, raw.text, raw.title);
if (!questionText) {
return null;
}
const options = normalizeOptions(firstArray(raw.options, raw.choices, raw.enum, raw.values));
const explicitType = normalizeQuestionType(firstString(raw.type, raw.questionType, raw.inputType, raw.responseType));
const multiSelect = Boolean(raw.multiSelect ?? raw.multiselect ?? raw.multiple ?? raw.allowMultiple ?? raw.multiple_choice);
if ((explicitType === "single_select" || explicitType === "multi_select") && options.length === 0) {
return null;
}
const type = inferQuestionType(raw, options, explicitType, multiSelect);
if ((type === "single_select" || type === "multi_select") && options.length === 0) {
return null;
}
return {
id: firstString(raw.id, raw.name, raw.key) ?? `q-${index}`,
type,
question: questionText,
header: firstString(raw.header, raw.heading) ?? undefined,
description: firstString(raw.description, raw.details, raw.helpText) ?? undefined,
options: options.length > 0 ? options : undefined,
multiSelect: type === "multi_select" ? true : multiSelect || undefined,
};
}
function inferQuestionType(
raw: Record<string, unknown>,
options: ChatQuestionOption[],
explicitType: PlanningQuestionType | null,
multiSelect: boolean,
): PlanningQuestionType {
if (explicitType) {
if (explicitType === "multi_select" && options.length === 0) return "text";
if (explicitType === "single_select" && options.length === 0) return "text";
return explicitType;
}
if (isBooleanSchema(raw, options)) {
return "confirm";
}
if (options.length > 0) {
return multiSelect ? "multi_select" : "single_select";
}
return "text";
}
function normalizeQuestionType(value: string | null): PlanningQuestionType | null {
if (!value) return null;
const normalized = value.toLowerCase().replace(/[\s-]+/g, "_");
if (normalized === "text" || normalized === "free_text" || normalized === "input") return "text";
if (normalized === "single_select" || normalized === "select" || normalized === "choice") return "single_select";
if (normalized === "multi_select" || normalized === "multiple_select" || normalized === "checkbox") return "multi_select";
if (normalized === "confirm" || normalized === "confirmation" || normalized === "boolean" || normalized === "yes_no") return "confirm";
return null;
}
function isBooleanSchema(raw: Record<string, unknown>, options: ChatQuestionOption[]): boolean {
const rawType = firstString(raw.type, raw.schemaType, raw.inputType, raw.responseType)?.toLowerCase().replace(/[\s-]+/g, "_");
if (rawType === "boolean" || rawType === "confirm" || rawType === "confirmation" || rawType === "yes_no") {
return true;
}
const schema = asRecord(raw.schema) ?? asRecord(raw.inputSchema) ?? asRecord(raw.parameters);
const schemaType = firstString(schema?.type, schema?.format)?.toLowerCase().replace(/[\s-]+/g, "_");
if (schemaType === "boolean" || schemaType === "yes_no") {
return true;
}
if (options.length !== 2) {
return false;
}
const labels = options.map((option) => option.label.trim().toLowerCase());
return labels.includes("yes") && labels.includes("no");
}
function normalizeOptions(rawOptions: unknown[] | null): ChatQuestionOption[] {
if (!rawOptions) return [];
return rawOptions
.map((rawOption, index) => {
const raw = asRecord(rawOption);
if (!raw) {
if (typeof rawOption === "string" || typeof rawOption === "number" || typeof rawOption === "boolean") {
return { id: `opt-${index}`, label: String(rawOption) };
}
return null;
}
const label = firstString(raw.label, raw.text, raw.name, raw.title, raw.value, raw.id);
if (!label) {
return null;
}
return {
id: firstString(raw.id, raw.value, raw.key) ?? `opt-${index}`,
label,
description: firstString(raw.description, raw.details, raw.helpText) ?? undefined,
};
})
.filter(isChatQuestionOption);
}
function formatAnswerValue(question: ChatQuestion, answer: ChatQuestionAnswerValue | undefined): string {
if (answer === undefined) {
return "(no answer)";
}
if (question.type === "confirm") {
return answer === true ? "Yes" : "No";
}
if (Array.isArray(answer)) {
const selectedLabels = answer.map((id) => optionLabelForId(question, id)).filter(Boolean);
return selectedLabels.length > 0 ? selectedLabels.join(", ") : "(no answer)";
}
if (question.type === "single_select") {
return optionLabelForId(question, String(answer)) ?? String(answer);
}
return String(answer).trim() || "(no answer)";
}
function optionLabelForId(question: ChatQuestion, id: string): string | null {
return question.options?.find((option) => option.id === id)?.label ?? null;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function firstString(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
}
return null;
}
function firstArray(...values: unknown[]): unknown[] | null {
return values.find(Array.isArray) ?? null;
}
function isChatQuestion(value: ChatQuestion | null): value is ChatQuestion {
return value !== null;
}
function isChatQuestionOption(value: ChatQuestionOption | null): value is ChatQuestionOption {
return value !== null;
}

View File

@@ -1250,6 +1250,16 @@
"noSkillsAvailable": "No skills available",
"noSkillsFound": "No skills found",
"openQuickChat": "Open quick chat",
"questionAnsweredLabel": "Answered",
"questionAnsweredWithoutContent": "A later user reply answered this question.",
"questionConfirmNo": "No",
"questionConfirmYes": "Yes",
"questionResponseEyebrow": "Assistant question",
"questionResponseLabel": "Question from assistant",
"questionSelectHint": "Answer all questions to continue the chat.",
"questionSubmit": "Send answer",
"questionSubmittedAnswerLabel": "Submitted answer",
"questionTextPlaceholder": "Type your answer here…",
"queuedMessage": "Queued: {{preview}}",
"quickChatTitle": "Quick Chat",
"relativeTimeDays_one": "{{count}}d ago",