mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
- Add Tauri sidecar architecture: Rust shell spawns claude-server binary, dynamic port allocation, health-check wait loop, graceful shutdown - Fix CORS middleware to accept `tauri://localhost` and `https://tauri.localhost` origins from Tauri WebView, and add CORS headers to /health endpoint - Enable native macOS window decorations (traffic lights) with Overlay title bar, add data-tauri-drag-region on sidebar for window dragging - Conditionally apply desktop-only padding (44px for traffic lights) vs web (12px) - Generate brand identity: light-background app icon, horizontal logo, full icon set (icns/ico/png) for Tauri bundle - Add brand mark + GitHub link in sidebar, replace mascot SVG with app icon in EmptySession page - Update README (zh/en) and docs hero image with new branding - Add sidecar build scripts and launcher entry points - Gitignore Rust target/, Tauri gen/, and brand-assets candidates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
260 lines
9.6 KiB
TypeScript
260 lines
9.6 KiB
TypeScript
import { useState } from 'react'
|
|
import { useChatStore } from '../../stores/chatStore'
|
|
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
|
|
}
|
|
|
|
/**
|
|
* 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: _toolUseId, input }: Props) {
|
|
const { sendMessage } = useChatStore()
|
|
const questions = parseInput(input)
|
|
const [activeTab, setActiveTab] = useState(0)
|
|
const [selections, setSelections] = useState<Record<number, string>>({})
|
|
const [freeText, setFreeText] = useState('')
|
|
const [submitted, setSubmitted] = useState(false)
|
|
|
|
if (questions.length === 0) return null
|
|
|
|
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
|
|
|
|
setSubmitted(true)
|
|
sendMessage(response)
|
|
}
|
|
|
|
// 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 ml-10 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)]">
|
|
Claude needs your input
|
|
</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)]">
|
|
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">
|
|
Or type a custom response:
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={freeText}
|
|
onChange={(e) => {
|
|
setFreeText(e.target.value)
|
|
if (e.target.value.trim()) setSelections({})
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && allAnswered) handleSubmit()
|
|
}}
|
|
placeholder="Type your answer..."
|
|
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>
|
|
Answered: <strong>{freeText.trim() || Object.values(selections).join(', ')}</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}
|
|
onClick={handleSubmit}
|
|
icon={
|
|
<span className="material-symbols-outlined text-[14px]">send</span>
|
|
}
|
|
>
|
|
Submit
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|