Files
claude-code-haha/desktop/src/components/chat/AskUserQuestion.tsx
T
程序员阿江(Relakkes) 5a75fe6f1d feat(desktop): rebuild the H5 phone and tablet shell around touch
Replace the drawer-based mobile layout with a single navigation stack. Home
is the session list grouped by status (waiting for you, working, then by
day) with the new-task composer docked under it; a session, Settings and
subagent or team-member pages push on top, and the system back gesture
moves up one level. Touch tablets keep the list beside the page.

- Composer: one row (+, model, send); permission mode, context usage and
  the capability menu move into a + bottom sheet.
- Approvals: a waiting permission, question or plan takes the composer's
  place; the transcript keeps a one-line marker. Denying can carry a reason.
- Messages: press and hold for copy, select text, edit and branch instead
  of an always-visible icon row.
- Tool runs fold into one card per run and open the full timeline in a
  sheet; the turn change card opens a file's diff full screen.
- Activity: a pill in the session bar opens the activity sheet instead of
  auto-opening over the chat.
- Settings: one grouped list with the connection, provider, phone-editable
  preferences and the computer-only settings shown read-only.
- Server: GET /api/sessions/live-status reports every running or waiting
  session so the list can sort sessions the phone never opened.
2026-10-06 03:04:41 +08:00

688 lines
30 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react'
import {
listPendingPermissions,
useChatStore,
type AskUserQuestionDraft,
} from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useTranslation } from '../../i18n'
import { ArrowRight, Check, CircleCheck, Info, MessageCircleQuestion, MessagesSquare, Send } from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { PendingDecisionMarker } from './PendingDecisionMarker'
import {
ASK_USER_QUESTION_CLARIFY_WITH_QUESTIONS_PREFIX,
ASK_USER_QUESTION_EXPIRED_ANSWER_PREFIX,
} from '../../../../src/constants/messages'
type QuestionOption = {
label: string
description?: string
}
type Question = {
question: string
header?: string
options?: QuestionOption[]
multiSelect?: boolean
}
type Props = {
sessionId?: string | null
toolUseId: string
input: unknown
result?: unknown
/**
* A user message exists after this question in the transcript — the user has
* already moved past it, so no live permission request can still be on its way.
* Computed by buildRenderModel, the only place that sees the whole list.
*/
supersededByUserMessage?: boolean
/**
* While the question waits on a live request, draw one marker line instead
* of the card: the phone answers it from the approval bar, and two live
* copies of the card would each hold half an answer.
*/
markerWhenPending?: boolean
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
// The input is whatever the model emitted, and the transcript keeps it even when the
// server rejected the call as invalid — so a card is rebuilt from it on every replay
// (issue #1400: an option description that came back as an object threw React #31 and
// took the whole app down, again on every launch). Only string fields are trusted,
// because a non-string child throws while rendering; anything else is left out rather
// than shown. That cleanup is display-only: the answer still travels with the
// original input (see handleSubmit).
function toOption(value: unknown): QuestionOption | null {
if (!isRecord(value) || typeof value.label !== 'string') return null
return typeof value.description === 'string'
? { label: value.label, description: value.description }
: { label: value.label }
}
function toQuestion(value: unknown): Question | null {
if (!isRecord(value) || typeof value.question !== 'string') return null
return {
question: value.question,
header: typeof value.header === 'string' ? value.header : undefined,
options: Array.isArray(value.options)
? value.options.flatMap((option) => toOption(option) ?? [])
: undefined,
multiSelect: value.multiSelect === true,
}
}
/**
* Parse the AskUserQuestion input which may come in different shapes.
*/
function parseInput(input: unknown): Question[] {
if (!isRecord(input)) return []
// Shape 1: { questions: [...] }
if (Array.isArray(input.questions)) {
return input.questions.flatMap((question) => toQuestion(question) ?? [])
}
// Shape 2: { question: "...", options: [...] }
const single = toQuestion(input)
return single ? [single] : []
}
type QuestionSelections = Record<number, string[]>
type QuestionFreeTexts = Record<number, string>
function getSelectedAnswer(question: Question, selected: string[] | undefined) {
if (!selected || selected.length === 0) return ''
return question.multiSelect ? selected.join(', ') : selected[0] ?? ''
}
export function AskUserQuestion({
sessionId,
toolUseId,
input,
result,
supersededByUserMessage,
markerWhenPending = false,
}: Props) {
const { respondToPermission } = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const targetSessionId = sessionId ?? activeTabId
const pendingRequest = useChatStore((s) => targetSessionId
? listPendingPermissions(s.sessions[targetSessionId])
.find((permission) => permission.toolUseId === toolUseId) ?? null
: null)
// The card is rendered from the transcript, but answering it needs the live
// permission request. Both facts below come from the session, never from the
// transcript, and they are what tells a live question from an expired one.
const sessionChatState = useChatStore((s) =>
targetSessionId ? s.sessions[targetSessionId]?.chatState : undefined)
const sessionConnectionState = useChatStore((s) =>
targetSessionId ? s.sessions[targetSessionId]?.connectionState : undefined)
const t = useTranslation()
const questions = useMemo(() => parseInput(input), [input])
const inputObject = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
// Read once instead of subscribing: this card writes the draft on every
// change, and a subscription would feed its own writes back as re-renders.
const storedDraft = targetSessionId
? useChatStore.getState().askUserQuestionDrafts[targetSessionId]?.[toolUseId]
: undefined
const [activeTab, setActiveTab] = useState(storedDraft?.activeTab ?? 0)
const [selections, setSelections] = useState<QuestionSelections>(storedDraft?.selections ?? {})
const [freeTexts, setFreeTexts] = useState<QuestionFreeTexts>(storedDraft?.freeTexts ?? {})
const [hasSubmitted, setHasSubmitted] = useState(false)
const [hasRequestedChat, setHasRequestedChat] = useState(storedDraft?.handedOff === true)
// The question had no live request left, so the answers went out as a message.
const [hasSentAsMessage, setHasSentAsMessage] = useState(storedDraft?.sentAsMessage === true)
const composingRef = useRef(false)
const activitySentForRequest = useRef<string | null>(null)
// A choice can be made before the SDK permission request reaches the UI.
// Protect that draft as soon as the matching request arrives.
useEffect(() => {
if (!targetSessionId || !pendingRequest || hasSubmitted ||
activitySentForRequest.current === pendingRequest.requestId) return
if (!Object.values(selections).some((labels) => labels.length > 0) &&
!Object.values(freeTexts).some((value) => value.trim())) return
activitySentForRequest.current = pendingRequest.requestId
useChatStore.getState().recordAskUserQuestionActivity(targetSessionId, pendingRequest.requestId)
}, [freeTexts, hasSubmitted, pendingRequest, selections, targetSessionId])
const resultAnswers = useMemo(() => {
if (!result || typeof result !== 'object') return {}
const answers = (result as { answers?: unknown }).answers
return answers && typeof answers === 'object'
? answers as Record<string, string>
: {}
}, [result])
const resultText = typeof result === 'string' && result.trim().length > 0 ? result.trim() : ''
const automaticallyAnswered = Boolean(result && typeof result === 'object' &&
(result as { selectionSource?: unknown }).selectionSource === 'automatic')
const hasStructuredAnswers = Object.keys(resultAnswers).length > 0
const hasTerminalResult = hasStructuredAnswers || resultText.length > 0
const answeredText = useMemo(() => {
if (hasStructuredAnswers) {
return questions
.map((question) => resultAnswers[question.question])
.filter((answer): answer is string => typeof answer === 'string' && answer.trim().length > 0)
.join(', ')
}
if (resultText) return resultText
return questions
.map((question, index) => freeTexts[index]?.trim() || getSelectedAnswer(question, selections[index]))
.filter(Boolean)
.join('; ')
}, [freeTexts, hasStructuredAnswers, questions, resultAnswers, resultText, selections])
// Hand the card's state to the store so it survives the unmounts that tab
// switches and the virtualized window cause — the answers are the user's work.
// Terminal states it can prove locally win over the ones the server records:
// `sentAsMessage` and the handoff leave no trace on the server, and dropping
// them would resurrect an answerable form that could deliver twice.
useEffect(() => {
if (!targetSessionId) return
const store = useChatStore.getState()
const draft: AskUserQuestionDraft = { activeTab, selections, freeTexts }
if (hasSentAsMessage) {
store.setAskUserQuestionDraft(targetSessionId, toolUseId, { ...draft, sentAsMessage: true })
return
}
if (hasRequestedChat) {
store.setAskUserQuestionDraft(targetSessionId, toolUseId, { ...draft, handedOff: true })
return
}
if (hasSubmitted || hasTerminalResult) {
store.clearAskUserQuestionDraft(targetSessionId, toolUseId)
return
}
store.setAskUserQuestionDraft(targetSessionId, toolUseId, draft)
}, [
activeTab,
freeTexts,
hasRequestedChat,
hasSentAsMessage,
hasSubmitted,
hasTerminalResult,
selections,
targetSessionId,
toolUseId,
])
// Every hook above this line runs unconditionally, and it has to stay that way.
// `input` is not fixed for the lifetime of the instance: chatStore rebuilds tool_use
// messages from the transcript under a stable id (`${messageId}-block-${index}`), so
// the same mounted component can see its question count cross zero in either
// direction. With the early return above the useMemo calls, that transition threw
// "Rendered fewer/more hooks than expected" and took the whole message list down.
if (questions.length === 0) return null
if (markerWhenPending && pendingRequest) {
return <PendingDecisionMarker title={t('mobile.approval.questionTitle')} />
}
const safeActiveTab = Math.min(activeTab, questions.length - 1)
const activeQuestion = questions[safeActiveTab]
const submitted = hasTerminalResult || hasSubmitted || hasRequestedChat || hasSentAsMessage
const terminalWithoutAnswers = submitted && !hasStructuredAnswers && resultText.length > 0
// Mid-turn states. Here "no request yet" means "not yet", not "never": the card
// renders as soon as the tool_use block is complete (`tool_use_complete` clears
// isPending), while the can_use_tool request only lands afterwards. Judging that
// window as expired would flash a bogus notice on every live question.
const sessionBusy =
sessionChatState === 'thinking' ||
sessionChatState === 'streaming' ||
sessionChatState === 'tool_executing' ||
sessionChatState === 'compacting' ||
sessionChatState === 'permission_pending'
// Delivering an expired answer means sending a message, which needs a connected
// session. While reconnecting, the permission snapshot may not have arrived yet,
// so stay neutral rather than declaring the question dead.
const canSendAsMessage = Boolean(targetSessionId) && sessionConnectionState === 'connected'
/**
* The question outlived its live permission request: whoever was waiting for it
* is gone (renderer was away, CLI reclaimed, turn interrupted) or the user has
* already replied in the composer. `respondToPermission` has nothing left to
* answer, so the card must say so instead of pretending to be a live prompt.
*
* `!pendingRequest` comes first and is absolute: while a request exists the
* answer MUST travel as a permission response — routing it to a message would
* drop the real prompt and can cancel the turn waiting on it.
*/
const expired = !submitted && !pendingRequest && canSendAsMessage &&
(supersededByUserMessage === true || !sessionBusy)
/** The `- "question"\n Answer: …` block every AskUserQuestion handoff uses. */
const describeAnswers = () => questions
.map((question, index) => {
const answer = freeTexts[index]?.trim() || getSelectedAnswer(question, selections[index])
return answer
? `- "${question.question}"\n Answer: ${answer}`
: `- "${question.question}"\n (No answer provided)`
})
.join('\n')
/**
* Delivers text to the model as an ordinary user message — only ever reached
* when no permission request is left to answer. Mirrors ChatInput's
* send-or-queue decision so a session that started a new turn between render
* and click is not interrupted mid-turn.
*/
const sendAsMessage = (content: string, displayContent: string) => {
const sessionId = targetSessionId
if (!sessionId) return
const store = useChatStore.getState()
const liveChatState = store.sessions[sessionId]?.chatState ?? 'idle'
if (liveChatState !== 'idle') {
store.queueUserMessage(sessionId, { content, displayContent })
return
}
store.sendMessage(sessionId, content, undefined, { displayContent })
}
const markUserActivity = () => {
if (!targetSessionId || !pendingRequest || submitted ||
activitySentForRequest.current === pendingRequest.requestId) return
activitySentForRequest.current = pendingRequest.requestId
useChatStore.getState().recordAskUserQuestionActivity(targetSessionId, pendingRequest.requestId)
}
const handleSelect = (qIndex: number, label: string) => {
if (submitted) return
markUserActivity()
// Computed from the render snapshot rather than inside the updater: React
// may run a state updater twice, and advancing the tab is a side effect.
// Clicking an already-selected option deselects it — that is not a step
// forward. Multi-select keeps the user on the question until they say so.
const shouldAdvance =
questions[qIndex]?.multiSelect !== true &&
!(selections[qIndex]?.includes(label) ?? false) &&
qIndex === safeActiveTab &&
qIndex < questions.length - 1
setSelections((prev) => {
const question = questions[qIndex]
const selected = prev[qIndex] ?? []
if (question?.multiSelect) {
const nextSelected = selected.includes(label)
? selected.filter((value) => value !== label)
: [...selected, label]
const next = { ...prev }
if (nextSelected.length > 0) {
next[qIndex] = nextSelected
} else {
delete next[qIndex]
}
return next
}
if (selected[0] === label) {
const next = { ...prev }
delete next[qIndex]
return next
}
return { ...prev, [qIndex]: [label] }
})
setFreeTexts((prev) => {
if (!prev[qIndex]) return prev
const next = { ...prev }
delete next[qIndex]
return next
})
if (shouldAdvance) setActiveTab(qIndex + 1)
}
const handleFreeTextChange = (qIndex: number, value: string) => {
if (submitted) return
markUserActivity()
setFreeTexts((prev) => {
const next = { ...prev }
if (value) {
next[qIndex] = value
} else {
delete next[qIndex]
}
return next
})
if (value.trim()) {
setSelections((prev) => {
if (!prev[qIndex]) return prev
const next = { ...prev }
delete next[qIndex]
return next
})
}
}
const handleSubmit = () => {
if (submitted) return
const parts: string[] = []
for (let i = 0; i < questions.length; i++) {
const answer = freeTexts[i]?.trim() || getSelectedAnswer(questions[i]!, selections[i])
if (answer) parts.push(answer)
}
const response = parts.join('; ')
if (!response) return
if (expired) {
setHasSentAsMessage(true)
sendAsMessage(
`${ASK_USER_QUESTION_EXPIRED_ANSWER_PREFIX}\n\n${describeAnswers()}`,
response,
)
return
}
if (!targetSessionId || !pendingRequest) return
const answers = questions.reduce<Record<string, string>>((acc, question, index) => {
const freeText = freeTexts[index]?.trim()
if (freeText) {
acc[question.question] = freeText
} else {
const selected = getSelectedAnswer(question, selections[index])
if (selected) acc[question.question] = selected
}
return acc
}, {})
setHasSubmitted(true)
const metadata = inputObject.metadata
respondToPermission(targetSessionId, pendingRequest.requestId, true, {
updatedInput: {
...inputObject,
answers,
...(metadata && typeof metadata === 'object' && !Array.isArray(metadata)
? { metadata: Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== 'autoAnswered')) }
: {}),
},
})
}
/**
* Hands the questions back to the model as a conversation instead of an
* answer — the user doesn't think any option fits and wants to talk first.
*
* Travels as a denial because that's the only channel that carries free text
* back to the model, but the server rewrites it (buildDenyMessage) into
* "ask them what they'd like to clarify" rather than the usual "STOP and
* wait". Deliberately not gated on `allAnswered`: not recognising your own
* question in any of the options is exactly when nothing is filled in.
*
* With no request left to deny (see `expired`), the same wording is sent as
* an ordinary message instead — the model still gets asked to follow up.
*/
const handleChatAboutThis = () => {
if (submitted) return
if (expired) {
const questionsWithAnswers = describeAnswers()
setHasRequestedChat(true)
// No request left to deny, so this goes out as an ordinary message and
// carries the same wording the server would have written for the denial.
sendAsMessage(
`${ASK_USER_QUESTION_CLARIFY_WITH_QUESTIONS_PREFIX}${questionsWithAnswers}`,
questionsWithAnswers,
)
return
}
if (!targetSessionId || !pendingRequest) return
setHasRequestedChat(true)
respondToPermission(targetSessionId, pendingRequest.requestId, false, {
// Carry whatever was already picked, so switching to a conversation isn't
// punished by losing the partial answers.
denyMessage: describeAnswers(),
})
}
// All questions must be answered (via selection or free text) to enable submit
const allAnswered = questions.every((_, i) =>
Boolean(freeTexts[i]?.trim()) || (selections[i]?.length ?? 0) > 0,
)
if (!activeQuestion) return null
// Gates the Next button: ChatGPT's `canAdvance`, so a step forward cannot
// silently drop a question the user never answered.
const activeAnswered =
Boolean(freeTexts[safeActiveTab]?.trim()) || (selections[safeActiveTab]?.length ?? 0) > 0
const statusBadgeKey = automaticallyAnswered
? 'question.autoAnswered'
: hasSentAsMessage || expired
? 'question.expiredBadge'
: hasRequestedChat
? 'question.chatBadge'
: terminalWithoutAnswers ? 'question.completed' : 'question.answered'
return (
<div className={`overflow-hidden rounded-[var(--radius-lg)] border bg-[var(--color-surface-container-lowest)] ${
submitted ? 'border-[var(--color-border)]' : 'border-[var(--color-outline)]'
}`}>
{/* Header: 42px, the shared card head of the ask / plan cards. */}
<div className={`flex h-[42px] items-center gap-2 px-3.5 ${submitted ? '' : 'border-b border-[var(--color-border)]'}`}>
<MessageCircleQuestion aria-hidden="true" size={15} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 truncate text-[13px] font-semibold text-[var(--color-text-primary)]">
{t('question.needsInput')}
</span>
{(submitted || expired) && (
// handing the question back is not an answer — saying "answered"
// there misreports what the user did; and an expired question was
// never answered at all, whatever happened to the answers
<Badge className="shrink-0">{t(statusBadgeKey)}</Badge>
)}
{!submitted && questions.length > 1 && (
<span className="ml-auto shrink-0 font-mono text-xs tabular-nums text-[var(--color-text-tertiary)]">
<span className="font-medium text-[var(--color-text-primary)]">{safeActiveTab + 1}</span> / {questions.length}
</span>
)}
</div>
{/* Question tabs (only when there are several questions) */}
{!submitted && questions.length > 1 && (
<div className="flex gap-0.5 overflow-x-auto px-2.5 pt-2">
{questions.map((q, i) => {
const isActive = safeActiveTab === i
const isAnswered = Boolean(freeTexts[i]?.trim()) || (selections[i]?.length ?? 0) > 0
const tabLabel = q.header || `Q${i + 1}`
return (
<button
key={i}
type="button"
onClick={() => setActiveTab(i)}
aria-current={isActive || undefined}
className={`flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[var(--radius-sm)] px-2.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
isActive
? 'bg-[var(--color-surface-container)] font-medium text-[var(--color-text-primary)]'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]'
}`}
>
{isAnswered && (
<CircleCheck aria-hidden="true" size={12} strokeWidth={2} className="text-[var(--color-success)]" />
)}
{tabLabel}
</button>
)
})}
</div>
)}
{/* Active question content */}
{!submitted && (
<div className="px-2.5 pt-3">
{/* Nothing is waiting on this question any more. The form below stays
usable on purpose — the answers are still worth sending — but which
channel they take changes, and that has to be visible. */}
{expired && (
<div
role="status"
aria-live="polite"
className="mx-1 mb-3 flex items-start gap-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 text-xs text-[var(--color-text-secondary)]"
>
<Info aria-hidden="true" size={14} strokeWidth={1.75} className="mt-px shrink-0 text-[var(--color-text-tertiary)]" />
<span>{t('question.expiredNotice')}</span>
</div>
)}
<p className="mb-2 px-1 text-sm font-medium text-[var(--color-text-primary)]">
{activeQuestion.question}
</p>
{/* Option cards: hairline at rest, a 1.5px terracotta edge on the
faint brand wash when picked. */}
{activeQuestion.options && activeQuestion.options.length > 0 && (
<div className="grid gap-1.5">
{activeQuestion.options.map((opt, optIndex) => {
const isSelected = selections[safeActiveTab]?.includes(opt.label) ?? false
const isMultiSelect = activeQuestion.multiSelect === true
return (
<button
key={optIndex}
type="button"
onClick={() => handleSelect(safeActiveTab, opt.label)}
disabled={submitted}
aria-pressed={isSelected}
className={`flex w-full cursor-pointer items-start gap-2.5 rounded-[var(--radius-md)] px-3 py-2.5 text-left transition-[background-color,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
isSelected
? 'bg-[var(--color-brand-soft)] shadow-[inset_0_0_0_1.5px_var(--color-brand)]'
: 'shadow-[inset_0_0_0_1px_var(--color-border)] hover:shadow-[inset_0_0_0_1px_var(--color-outline)]'
}`}
>
{/* Selection indicator: a ring that fills in for single
choice, a checkbox for multi-select. */}
<span
aria-hidden="true"
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center transition-[background-color,box-shadow] ${
isMultiSelect ? 'rounded-[var(--radius-xs)]' : 'rounded-full'
} ${
isSelected
? isMultiSelect
? 'bg-[var(--color-brand)] text-[var(--color-on-primary)]'
: 'shadow-[inset_0_0_0_5px_var(--color-brand)]'
: 'shadow-[inset_0_0_0_1.5px_var(--color-border-strong)]'
}`}
>
{isSelected && isMultiSelect ? <Check size={12} strokeWidth={2.5} /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13px] font-medium text-[var(--color-text-primary)]">
{opt.label}
</span>
{opt.description && (
<span className="mt-0.5 block text-xs text-[var(--color-text-tertiary)]">
{opt.description}
</span>
)}
</span>
</button>
)
})}
</div>
)}
{/* Free text input: the sunken answer slot. */}
<div className="mt-2.5">
<label className="mb-1 block px-1 text-xs text-[var(--color-text-tertiary)]">
{t('question.customResponse')}
</label>
<textarea
value={freeTexts[safeActiveTab] ?? ''}
onChange={(e) => handleFreeTextChange(safeActiveTab, e.target.value)}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={(e) => {
if (composingRef.current || e.nativeEvent.isComposing || e.keyCode === 229) return
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && allAnswered) {
e.preventDefault()
handleSubmit()
}
}}
placeholder={t('question.typePlaceholder')}
rows={3}
wrap="soft"
className="max-h-48 min-h-[72px] w-full resize-y rounded-[var(--radius-md)] border border-transparent bg-[var(--color-surface-container)] px-3 py-2.5 text-[13px] leading-relaxed text-[var(--color-text-primary)] transition-colors placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-outline)] focus:bg-[var(--color-surface-container-lowest)] focus:outline-none"
/>
</div>
</div>
)}
{/* Submitted answer display — the chat handoff wins over any terminal
result, whose text is the deny payload and not worth showing. */}
{submitted && (
<div className="border-t border-[var(--color-border)] px-3.5 py-2.5">
<p className="mb-1 truncate text-xs text-[var(--color-text-tertiary)]">{activeQuestion.question}</p>
{hasSentAsMessage ? (
<div className="flex items-center gap-2 text-[13px] text-[var(--color-text-secondary)]">
<Send aria-hidden="true" size={14} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span>
{t('question.sentAsMessagePrefix')}<strong className="font-medium text-[var(--color-text-primary)]">{answeredText}</strong>
</span>
</div>
) : hasRequestedChat ? (
<div className="flex items-center gap-2 text-[13px] text-[var(--color-text-secondary)]">
<MessagesSquare aria-hidden="true" size={14} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span>{t('question.chatRequested')}</span>
</div>
) : (
<div className="flex items-center gap-2 text-[13px] text-[var(--color-text-secondary)]">
<CircleCheck aria-hidden="true" size={14} strokeWidth={1.75} className="shrink-0 text-[var(--color-success)]" />
<span>
{t(terminalWithoutAnswers ? 'question.resultPrefix' : 'question.answeredPrefix')}<strong className="font-medium text-[var(--color-text-primary)]">{answeredText}</strong>
</span>
</div>
)}
</div>
)}
{/* Action bar. Wraps rather than overflows: the buttons plus a translated
label (kr/jp run long) can outgrow a narrow side-by-side pane. Next is
pushed to the end of the row and wraps alone onto a second line when
it runs out of room. */}
{!submitted && (
<div className="flex flex-wrap items-center gap-2 px-3.5 pb-3.5 pt-3">
<Button
variant="primary"
size="base"
// `expired` is the one state where a button may work without a live
// request: it sends the answers as a message instead of answering.
disabled={!allAnswered || (!expired && !pendingRequest)}
onClick={handleSubmit}
>
{t(expired ? 'question.sendAsMessage' : 'question.submit')}
</Button>
<Button
variant="ghost"
size="base"
disabled={!expired && !pendingRequest}
onClick={handleChatAboutThis}
title={t('question.chatAboutThisHint')}
icon={<MessagesSquare aria-hidden="true" size={14} strokeWidth={1.75} />}
>
{t('question.chatAboutThis')}
</Button>
{/* Only on multi-question input, and never on the last one. Pushed to
the end of the row so it stays out of the way of Submit, which is
the action that actually ends the exchange. */}
{questions.length > 1 && safeActiveTab < questions.length - 1 && (
<Button
variant="secondary"
size="base"
className="ml-auto"
disabled={!activeAnswered}
onClick={() => setActiveTab(safeActiveTab + 1)}
icon={<ArrowRight aria-hidden="true" size={14} strokeWidth={1.75} />}
iconPosition="end"
>
{t('question.next')}
</Button>
)}
</div>
)}
</div>
)
}