cc-haha/desktop/src/components/chat/AskUserQuestion.tsx
程序员阿江(Relakkes) bef9596ed5 Keep all assistant-side transcript content on one shared content rail
The desktop transcript had drifted into multiple competing left edges.
Assistant bubbles, thinking rows, tool cards, permissions, and standalone
results were using different offsets, which made the timeline feel visually
broken even when the underlying data was correct.

This change removes the ad hoc assistant-side indentation and makes the
assistant output lane follow the same content rail as the composer. The
assistant message component still distinguishes short bubble replies from
markdown-heavy document replies, but both now sit on the same shared left
alignment. Supporting chat blocks were updated to use that same rail so the
whole transcript reads as one coherent column.

Constraint: Assistant transcript content must align with the composer rail, not with local per-block offsets
Rejected: Keep tool/thinking blocks on a separate inset lane | creates multiple left edges and keeps the transcript visually inconsistent
Rejected: Fix only final assistant replies | leaves the rest of the assistant-side timeline misaligned
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Treat the composer rail as the canonical left edge for all assistant-side transcript blocks unless the entire transcript layout is redesigned together
Tested: bun run test src/components/chat/MessageList.test.tsx --run; bun run lint; bun run build
Not-tested: Real Tauri runtime screenshot against a live session after this unified alignment change
2026-04-22 15:30:05 +08:00

307 lines
12 KiB
TypeScript

import { useMemo, useRef, useState } from 'react'
import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useTranslation } from '../../i18n'
import { Button } from '../shared/Button'
type QuestionOption = {
label: string
description?: string
}
type Question = {
question: string
header?: string
options?: QuestionOption[]
}
type AskUserInput = {
questions?: Question[]
question?: string
options?: QuestionOption[]
}
type Props = {
toolUseId: string
input: unknown
result?: unknown
}
/**
* Parse the AskUserQuestion input which may come in different shapes.
*/
function parseInput(input: unknown): Question[] {
if (!input || typeof input !== 'object') return []
const obj = input as AskUserInput
// Shape 1: { questions: [...] }
if (Array.isArray(obj.questions)) {
return obj.questions
}
// Shape 2: { question: "...", options: [...] }
if (typeof obj.question === 'string') {
return [{ question: obj.question, options: obj.options }]
}
return []
}
export function AskUserQuestion({ toolUseId, input, result }: Props) {
const { respondToPermission } = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const pendingPermission = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.pendingPermission : undefined)
const t = useTranslation()
const questions = parseInput(input)
const inputObject = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
const [activeTab, setActiveTab] = useState(0)
const [selections, setSelections] = useState<Record<number, string>>({})
const [freeText, setFreeText] = useState('')
const [hasSubmitted, setHasSubmitted] = useState(false)
const composingRef = useRef(false)
if (questions.length === 0) return null
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 pendingRequest = pendingPermission?.toolUseId === toolUseId ? pendingPermission : null
const answeredText = useMemo(() => {
if (Object.keys(resultAnswers).length > 0) {
return questions
.map((question) => resultAnswers[question.question])
.filter((answer): answer is string => typeof answer === 'string' && answer.trim().length > 0)
.join(', ')
}
return freeText.trim() || Object.values(selections).join(', ')
}, [freeText, questions, resultAnswers, selections])
const submitted = Object.keys(resultAnswers).length > 0 || hasSubmitted
const handleSelect = (qIndex: number, label: string) => {
if (submitted) return
setSelections((prev) => {
// Toggle: deselect if already selected
if (prev[qIndex] === label) {
const next = { ...prev }
delete next[qIndex]
return next
}
return { ...prev, [qIndex]: label }
})
setFreeText('')
}
const handleSubmit = () => {
if (submitted) return
const parts: string[] = []
for (let i = 0; i < questions.length; i++) {
const selected = selections[i]
if (selected) parts.push(selected)
}
const response = freeText.trim() || parts.join('; ') || ''
if (!response) return
if (!activeTabId || !pendingRequest) return
const answers = questions.reduce<Record<string, string>>((acc, question, index) => {
if (freeText.trim()) {
acc[question.question] = freeText.trim()
} else if (selections[index]) {
acc[question.question] = selections[index]!
}
return acc
}, {})
setHasSubmitted(true)
respondToPermission(activeTabId, pendingRequest.requestId, true, {
updatedInput: {
...inputObject,
answers,
},
})
}
// All questions must be answered (via selection or free text) to enable submit
const allAnswered = freeText.trim().length > 0 || questions.every((_, i) => selections[i] !== undefined)
const safeActiveTab = Math.min(activeTab, questions.length - 1)
const activeQuestion = questions[safeActiveTab]
if (!activeQuestion) return null
return (
<div className={`mb-4 rounded-[var(--radius-lg)] border overflow-hidden ${
submitted
? 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-low)] opacity-70'
: 'border-[var(--color-secondary)] bg-[var(--color-surface-container-lowest)]'
}`}>
{/* Header */}
<div className={`flex items-center gap-3 px-4 py-3 ${
submitted
? 'bg-[var(--color-surface-container-low)]'
: 'bg-[var(--color-surface-container)]'
}`}>
<div className="flex items-center justify-center w-8 h-8 rounded-[var(--radius-md)] bg-[var(--color-secondary)]/10">
<span className="material-symbols-outlined text-[18px] text-[var(--color-secondary)]">
help
</span>
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('question.needsInput')}
</span>
{submitted && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]">
{t('question.answered')}
</span>
)}
</div>
</div>
{/* Question tabs — horizontal tab bar (only show when multiple questions) */}
{questions.length > 1 && (
<div className="flex px-4 border-b border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)] overflow-x-auto">
{questions.map((q, i) => {
const isActive = safeActiveTab === i
const isAnswered = selections[i] !== undefined
const tabLabel = q.header || `Q${i + 1}`
return (
<button
key={i}
onClick={() => setActiveTab(i)}
className={`relative flex items-center gap-1.5 px-4 py-2.5 text-xs font-medium whitespace-nowrap transition-colors ${
isActive
? 'text-[var(--color-secondary)]'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
}`}
>
{isAnswered && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span>
)}
{tabLabel}
{isActive && (
<div className="absolute bottom-0 left-2 right-2 h-[2px] bg-[var(--color-secondary)] rounded-t" />
)}
</button>
)
})}
</div>
)}
{/* Active question content */}
<div className="px-4 py-3">
<p className="text-sm font-medium text-[var(--color-text-primary)] mb-3">
{activeQuestion.question}
</p>
{/* Option cards */}
{activeQuestion.options && activeQuestion.options.length > 0 && (
<div className="space-y-2 mb-3">
{activeQuestion.options.map((opt, optIndex) => {
const isSelected = selections[activeTab] === opt.label
return (
<button
key={optIndex}
onClick={() => handleSelect(safeActiveTab, opt.label)}
disabled={submitted}
className={`w-full text-left px-4 py-3 rounded-[var(--radius-md)] border transition-all duration-150 cursor-pointer ${
isSelected
? 'border-[var(--color-secondary)] bg-[var(--color-secondary)]/8 ring-1 ring-[var(--color-secondary)]/30'
: 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface)] hover:border-[var(--color-outline-variant)] hover:bg-[var(--color-surface-container-low)]'
} ${submitted ? 'cursor-default' : ''}`}
>
<div className="flex items-start gap-3">
{/* Check indicator */}
<div className={`mt-0.5 flex-shrink-0 w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors ${
isSelected
? 'border-[var(--color-secondary)] bg-[var(--color-secondary)]'
: 'border-[var(--color-outline)]'
}`}>
{isSelected && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<span className={`text-sm font-medium ${
isSelected
? 'text-[var(--color-secondary)]'
: 'text-[var(--color-text-primary)]'
}`}>
{opt.label}
</span>
{opt.description && (
<p className="text-xs text-[var(--color-text-secondary)] mt-0.5">
{opt.description}
</p>
)}
</div>
</div>
</button>
)
})}
</div>
)}
{/* Free text input */}
{!submitted && (
<div>
<label className="text-xs text-[var(--color-text-tertiary)] mb-1.5 block">
{t('question.customResponse')}
</label>
<input
type="text"
value={freeText}
onChange={(e) => {
setFreeText(e.target.value)
if (e.target.value.trim()) setSelections({})
}}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={(e) => {
if (composingRef.current || e.nativeEvent.isComposing || e.keyCode === 229) return
if (e.key === 'Enter' && allAnswered) handleSubmit()
}}
placeholder={t('question.typePlaceholder')}
className="w-full px-3 py-2 text-sm bg-[var(--color-surface)] border border-[var(--color-outline-variant)]/40 rounded-[var(--radius-md)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-secondary)] focus:ring-1 focus:ring-[var(--color-secondary)]/30"
/>
</div>
)}
{/* Submitted answer display */}
{submitted && (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span>
<span>
{t('question.answeredPrefix')}<strong>{answeredText}</strong>
</span>
</div>
)}
</div>
{/* Submit button */}
{!submitted && (
<div className="flex items-center gap-2 px-4 py-3 border-t border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)]">
<Button
variant="primary"
size="sm"
disabled={!allAnswered || !pendingRequest}
onClick={handleSubmit}
icon={
<span className="material-symbols-outlined text-[14px]">send</span>
}
>
{t('question.submit')}
</Button>
</div>
)}
</div>
)
}