- Delete legacy hooks: useClaude.ts, useOpenCode.ts, usePiMono.ts, App.backup.tsx - Update all components to use usePi instead of legacy hooks - Replace useVisiblePiMonoModels/useClaudeModels/useOpenCodeModels with useVisiblePiModels - Migrate from LegacyChatMessage to ChatMessage type throughout - Update SessionBar to remove provider and archive props - Simplify ChatDetailPanel to Pi-only (remove Claude/OpenCode components) - Fix useChatSessions calls (remove provider parameter) - Update user-settings types: provider now only 'pi' instead of legacy values - Update PI_HARNESS_REBUILD.md to mark phases complete
165 lines
6.0 KiB
TypeScript
165 lines
6.0 KiB
TypeScript
import { useState } from 'react';
|
|
import { MessageCircleQuestion, Check } from 'lucide-react';
|
|
import type { ChatMessage } from './types';
|
|
|
|
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
|
|
|
type QuestionOption = {
|
|
label: string;
|
|
description: string;
|
|
};
|
|
|
|
type Question = {
|
|
question: string;
|
|
header: string;
|
|
multiple: boolean;
|
|
options: QuestionOption[];
|
|
};
|
|
|
|
type QuestionActivityProps = {
|
|
message: ToolMessage;
|
|
onAnswer: (text: string) => void;
|
|
};
|
|
|
|
export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) => {
|
|
const [selectedOptions, setSelectedOptions] = useState<Set<string>>(new Set());
|
|
const [otherText, setOtherText] = useState('');
|
|
const [answered, setAnswered] = useState(false);
|
|
const [answeredText, setAnsweredText] = useState('');
|
|
|
|
const input = message.toolInput as { questions?: Question[] };
|
|
const questions = input.questions;
|
|
if (!questions || questions.length === 0) return null;
|
|
|
|
const pending = message.output === undefined;
|
|
|
|
const handleSelect = (question: Question, label: string) => {
|
|
if (answered || !pending) return;
|
|
|
|
if (question.multiple) {
|
|
setSelectedOptions((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(label)) next.delete(label);
|
|
else next.add(label);
|
|
return next;
|
|
});
|
|
} else {
|
|
const text = label;
|
|
setAnswered(true);
|
|
setAnsweredText(text);
|
|
onAnswer(text);
|
|
}
|
|
};
|
|
|
|
const handleSubmitMultiple = () => {
|
|
if (selectedOptions.size === 0 || answered || !pending) return;
|
|
const text = Array.from(selectedOptions).join(', ');
|
|
setAnswered(true);
|
|
setAnsweredText(text);
|
|
onAnswer(text);
|
|
};
|
|
|
|
const handleSubmitOther = () => {
|
|
const text = otherText.trim();
|
|
if (!text || answered || !pending) return;
|
|
setAnswered(true);
|
|
setAnsweredText(text);
|
|
onAnswer(text);
|
|
};
|
|
|
|
const isDisabled = answered || !pending;
|
|
|
|
return (
|
|
<div className="my-1 space-y-3">
|
|
{questions.map((q, qi) => (
|
|
<div key={qi} className="rounded-xl border border-duck-teal/20 bg-background/90 overflow-hidden">
|
|
<div className="flex items-center gap-2 px-4 py-2.5 bg-duck-teal/5 border-b border-duck-teal/10">
|
|
<MessageCircleQuestion className="h-4 w-4 text-duck-teal shrink-0" />
|
|
<span className="text-xs font-medium text-duck-teal uppercase tracking-wider">{q.header}</span>
|
|
</div>
|
|
|
|
<div className="px-4 py-3 space-y-3">
|
|
<p className="text-sm text-duck-dark font-medium">{q.question}</p>
|
|
|
|
<div className="space-y-1.5">
|
|
{q.options.map((opt) => {
|
|
const isSelected = answered
|
|
? answeredText === opt.label || answeredText.split(', ').includes(opt.label)
|
|
: selectedOptions.has(opt.label);
|
|
|
|
return (
|
|
<button
|
|
key={opt.label}
|
|
onClick={() => handleSelect(q, opt.label)}
|
|
disabled={isDisabled}
|
|
className={`w-full text-left px-3 py-2 rounded-lg border text-sm transition-colors ${
|
|
isSelected
|
|
? 'border-duck-teal bg-duck-teal/10 text-duck-dark'
|
|
: isDisabled
|
|
? 'border-duck-dark/10 bg-duck-dark/5 text-duck-dark/40 cursor-not-allowed'
|
|
: 'border-duck-dark/15 hover:border-duck-teal/40 hover:bg-duck-teal/5 text-duck-dark cursor-pointer'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{isSelected && <Check className="h-3.5 w-3.5 text-duck-teal shrink-0" />}
|
|
<div>
|
|
<span className="font-medium">{opt.label}</span>
|
|
{opt.description && <span className="text-duck-dark/50 ml-1.5">— {opt.description}</span>}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* "Other" free-text option */}
|
|
{!isDisabled && (
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={otherText}
|
|
onChange={(ev) => setOtherText(ev.target.value)}
|
|
onKeyDown={(ev) => {
|
|
if (ev.key === 'Enter') {
|
|
ev.preventDefault();
|
|
handleSubmitOther();
|
|
}
|
|
}}
|
|
placeholder="Other..."
|
|
className="flex-1 px-3 py-1.5 rounded-lg border border-duck-dark/15 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:border-duck-teal/40"
|
|
/>
|
|
<button
|
|
onClick={handleSubmitOther}
|
|
disabled={!otherText.trim()}
|
|
className="px-3 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
|
|
>
|
|
Send
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Submit button for multi-select */}
|
|
{q.multiple && !isDisabled && (
|
|
<button
|
|
onClick={handleSubmitMultiple}
|
|
disabled={selectedOptions.size === 0}
|
|
className="px-4 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
|
|
>
|
|
Submit ({selectedOptions.size} selected)
|
|
</button>
|
|
)}
|
|
|
|
{/* Answered indicator */}
|
|
{isDisabled && answeredText && (
|
|
<div className="flex items-center gap-1.5 text-xs text-duck-teal">
|
|
<Check className="h-3 w-3" />
|
|
<span>Answered: {answeredText}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|