mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-22 00:27:35 +00:00
feature: Ask User Question implementation for Claude Code & upgrade claude agent sdk to 0.1.71 to support the tool
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React, { memo, useMemo, useCallback } from 'react';
|
||||
import { getToolConfig } from './configs/toolConfigs';
|
||||
import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent } from './components';
|
||||
import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent } from './components';
|
||||
import type { Project } from '../../../types/app';
|
||||
|
||||
type DiffLine = {
|
||||
@@ -31,6 +31,7 @@ function getToolCategory(toolName: string): string {
|
||||
if (['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'].includes(toolName)) return 'task';
|
||||
if (toolName === 'Task') return 'agent'; // Subagent task
|
||||
if (toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') return 'plan';
|
||||
if (toolName === 'AskUserQuestion') return 'question';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
@@ -156,6 +157,15 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
||||
contentComponent = <TaskListContent content={contentProps.content || ''} />;
|
||||
break;
|
||||
|
||||
case 'question-answer':
|
||||
contentComponent = (
|
||||
<QuestionAnswerContent
|
||||
questions={contentProps.questions || []}
|
||||
answers={contentProps.answers || {}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
contentComponent = (
|
||||
<TextContent
|
||||
|
||||
@@ -23,6 +23,7 @@ const borderColorMap: Record<string, string> = {
|
||||
task: 'border-l-violet-500 dark:border-l-violet-400',
|
||||
agent: 'border-l-purple-500 dark:border-l-purple-400',
|
||||
plan: 'border-l-indigo-500 dark:border-l-indigo-400',
|
||||
question: 'border-l-blue-500 dark:border-l-blue-400',
|
||||
default: 'border-l-gray-300 dark:border-l-gray-600',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Question } from '../../../types/types';
|
||||
|
||||
interface QuestionAnswerContentProps {
|
||||
questions: Question[];
|
||||
answers: Record<string, string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Exception to the stateless ContentRenderer pattern: multi-question navigation requires local state.
|
||||
export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||
questions,
|
||||
answers,
|
||||
className = '',
|
||||
}) => {
|
||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
||||
|
||||
if (!questions || questions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAnyAnswer = Object.keys(answers || {}).length > 0;
|
||||
const total = questions.length;
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{questions.map((q, idx) => {
|
||||
const answer = answers?.[q.question];
|
||||
const answerLabels = answer ? answer.split(', ') : [];
|
||||
const skipped = !answer;
|
||||
const isExpanded = expandedIdx === idx;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="rounded-lg border border-gray-150 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedIdx(isExpanded ? null : idx)}
|
||||
className="w-full text-left px-3 py-2 flex items-start gap-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<div className={`mt-0.5 flex-shrink-0 w-4 h-4 rounded-full flex items-center justify-center ${
|
||||
answerLabels.length > 0
|
||||
? 'bg-blue-100 dark:bg-blue-900/40'
|
||||
: 'bg-gray-100 dark:bg-gray-800'
|
||||
}`}>
|
||||
{answerLabels.length > 0 ? (
|
||||
<svg className="w-2.5 h-2.5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 dark:bg-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{q.header && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border border-blue-100/80 dark:border-blue-800/40">
|
||||
{q.header}
|
||||
</span>
|
||||
)}
|
||||
{total > 1 && (
|
||||
<span className="text-[10px] tabular-nums text-gray-400 dark:text-gray-500">
|
||||
{idx + 1}/{total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5 leading-snug">
|
||||
{q.question}
|
||||
</div>
|
||||
|
||||
{!isExpanded && answerLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{answerLabels.map((lbl) => {
|
||||
const isCustom = !q.options.some(o => o.label === lbl);
|
||||
return (
|
||||
<span
|
||||
key={lbl}
|
||||
className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded-md bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium"
|
||||
>
|
||||
{lbl}
|
||||
{isCustom && (
|
||||
<span className="text-[9px] text-blue-400 dark:text-blue-500 font-normal">(custom)</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isExpanded && skipped && hasAnyAnswer && (
|
||||
<span className="inline-block mt-1 text-[10px] text-gray-400 dark:text-gray-500 italic">
|
||||
Skipped
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 mt-0.5 text-gray-400 dark:text-gray-500 flex-shrink-0 transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-2.5 pt-0.5 border-t border-gray-100 dark:border-gray-700/40">
|
||||
<div className="space-y-1 ml-6.5">
|
||||
{q.options.map((opt) => {
|
||||
const wasSelected = answerLabels.includes(opt.label);
|
||||
return (
|
||||
<div
|
||||
key={opt.label}
|
||||
className={`flex items-start gap-2 px-2.5 py-1.5 rounded-lg text-[12px] ${
|
||||
wasSelected
|
||||
? 'bg-blue-50/80 dark:bg-blue-900/20 border border-blue-200/60 dark:border-blue-800/40'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className={`mt-0.5 flex-shrink-0 w-3.5 h-3.5 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} border-[1.5px] flex items-center justify-center ${
|
||||
wasSelected
|
||||
? 'border-blue-500 dark:border-blue-400 bg-blue-500 dark:bg-blue-500'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}>
|
||||
{wasSelected && (
|
||||
<svg className="w-2 h-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={wasSelected ? 'text-gray-900 dark:text-gray-100 font-medium' : ''}>
|
||||
{opt.label}
|
||||
</span>
|
||||
{opt.description && (
|
||||
<span className={`block text-[11px] mt-0.5 ${
|
||||
wasSelected ? 'text-blue-600/70 dark:text-blue-300/70' : 'text-gray-400 dark:text-gray-600'
|
||||
}`}>
|
||||
{opt.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (
|
||||
<div
|
||||
key={lbl}
|
||||
className="flex items-start gap-2 px-2.5 py-1.5 rounded-lg text-[12px] bg-blue-50/80 dark:bg-blue-900/20 border border-blue-200/60 dark:border-blue-800/40"
|
||||
>
|
||||
<div className={`mt-0.5 flex-shrink-0 w-3.5 h-3.5 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} border-[1.5px] border-blue-500 dark:border-blue-400 bg-blue-500 dark:bg-blue-500 flex items-center justify-center`}>
|
||||
<svg className="w-2 h-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-gray-900 dark:text-gray-100 font-medium">{lbl}</span>
|
||||
<span className="text-[10px] text-blue-500 dark:text-blue-400 ml-1">(custom)</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{skipped && hasAnyAnswer && (
|
||||
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic px-2.5 py-1">
|
||||
No answer provided
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!hasAnyAnswer && total === 1 && (
|
||||
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic">
|
||||
Skipped
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,3 +3,4 @@ export { FileListContent } from './FileListContent';
|
||||
export { TodoListContent } from './TodoListContent';
|
||||
export { TaskListContent } from './TaskListContent';
|
||||
export { TextContent } from './TextContent';
|
||||
export { QuestionAnswerContent } from './QuestionAnswerContent';
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { PermissionPanelProps } from '../../configs/permissionPanelRegistry';
|
||||
import type { Question } from '../../../types/types';
|
||||
|
||||
export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
|
||||
request,
|
||||
onDecision,
|
||||
}) => {
|
||||
const input = request.input as { questions?: Question[] } | undefined;
|
||||
const questions: Question[] = input?.questions || [];
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [selections, setSelections] = useState<Map<number, Set<string>>>(() => new Map());
|
||||
const [otherTexts, setOtherTexts] = useState<Map<number, string>>(() => new Map());
|
||||
const [otherActive, setOtherActive] = useState<Map<number, boolean>>(() => new Map());
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const otherInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setMounted(true));
|
||||
}, []);
|
||||
|
||||
// Focus the container for keyboard events when step changes
|
||||
useEffect(() => {
|
||||
if (!otherActive.get(currentStep)) {
|
||||
containerRef.current?.focus();
|
||||
}
|
||||
}, [currentStep, otherActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (otherActive.get(currentStep)) {
|
||||
otherInputRef.current?.focus();
|
||||
}
|
||||
}, [otherActive, currentStep]);
|
||||
|
||||
const toggleOption = useCallback((qIdx: number, label: string, multiSelect: boolean) => {
|
||||
setSelections(prev => {
|
||||
const next = new Map(prev);
|
||||
const current = new Set(next.get(qIdx) || []);
|
||||
if (multiSelect) {
|
||||
if (current.has(label)) current.delete(label);
|
||||
else current.add(label);
|
||||
} else {
|
||||
current.clear();
|
||||
current.add(label);
|
||||
setOtherActive(p => { const n = new Map(p); n.set(qIdx, false); return n; });
|
||||
}
|
||||
next.set(qIdx, current);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleOther = useCallback((qIdx: number, multiSelect: boolean) => {
|
||||
setOtherActive(prev => {
|
||||
const next = new Map(prev);
|
||||
const wasActive = next.get(qIdx) || false;
|
||||
next.set(qIdx, !wasActive);
|
||||
if (!multiSelect && !wasActive) {
|
||||
setSelections(p => { const n = new Map(p); n.set(qIdx, new Set()); return n; });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setOtherText = useCallback((qIdx: number, text: string) => {
|
||||
setOtherTexts(prev => { const next = new Map(prev); next.set(qIdx, text); return next; });
|
||||
}, []);
|
||||
|
||||
const buildAnswers = useCallback(() => {
|
||||
const answers: Record<string, string> = {};
|
||||
questions.forEach((q, idx) => {
|
||||
const selected = Array.from(selections.get(idx) || []);
|
||||
const isOther = otherActive.get(idx) || false;
|
||||
const otherText = (otherTexts.get(idx) || '').trim();
|
||||
if (isOther && otherText) selected.push(otherText);
|
||||
if (selected.length > 0) answers[q.question] = selected.join(', ');
|
||||
});
|
||||
return answers;
|
||||
}, [questions, selections, otherActive, otherTexts]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
onDecision(request.requestId, { allow: true, updatedInput: { ...input, answers: buildAnswers() } });
|
||||
}, [onDecision, request.requestId, input, buildAnswers]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
onDecision(request.requestId, { allow: true, updatedInput: { ...input, answers: {} } });
|
||||
}, [onDecision, request.requestId, input]);
|
||||
|
||||
// Keyboard handler for number keys and navigation
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
// Don't capture keys when typing in the "Other" input
|
||||
if (e.target instanceof HTMLInputElement) return;
|
||||
|
||||
const q = questions[currentStep];
|
||||
if (!q) return;
|
||||
const multi = q.multiSelect || false;
|
||||
const optCount = q.options.length;
|
||||
|
||||
// Number keys 1-9 for options
|
||||
const num = parseInt(e.key);
|
||||
if (!isNaN(num) && num >= 1 && num <= optCount) {
|
||||
e.preventDefault();
|
||||
toggleOption(currentStep, q.options[num - 1].label, multi);
|
||||
return;
|
||||
}
|
||||
|
||||
// 0 for "Other"
|
||||
if (e.key === '0') {
|
||||
e.preventDefault();
|
||||
toggleOther(currentStep, multi);
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter to advance / submit
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const isLast = currentStep === questions.length - 1;
|
||||
if (isLast) handleSubmit();
|
||||
else setCurrentStep(s => s + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape to skip
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleSkip();
|
||||
return;
|
||||
}
|
||||
}, [currentStep, questions, toggleOption, toggleOther, handleSubmit, handleSkip]);
|
||||
|
||||
if (questions.length === 0) return null;
|
||||
|
||||
const total = questions.length;
|
||||
const isSingle = total === 1;
|
||||
const q = questions[currentStep];
|
||||
const multi = q.multiSelect || false;
|
||||
const selected = selections.get(currentStep) || new Set<string>();
|
||||
const isOtherOn = otherActive.get(currentStep) || false;
|
||||
const isLast = currentStep === total - 1;
|
||||
const isFirst = currentStep === 0;
|
||||
const hasCurrentSelection = selected.size > 0 || (isOtherOn && (otherTexts.get(currentStep) || '').trim().length > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`w-full outline-none transition-all duration-500 ease-out ${
|
||||
mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-3'
|
||||
}`}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-2xl border border-gray-200/80 dark:border-gray-700/50 bg-white dark:bg-gray-800/90 shadow-lg dark:shadow-2xl">
|
||||
{/* Accent line */}
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-blue-500 via-cyan-400 to-teal-400" />
|
||||
|
||||
{/* Header + Question — compact */}
|
||||
<div className="px-4 pt-3.5 pb-2">
|
||||
<div className="flex items-center gap-2.5 mb-1.5">
|
||||
{/* Question icon */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-blue-500/10 to-cyan-500/10 dark:from-blue-400/15 dark:to-cyan-400/15 flex items-center justify-center">
|
||||
<svg className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.75} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827m0 3h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-cyan-400 dark:bg-cyan-500 animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className="text-[10px] font-medium tracking-wide uppercase text-gray-400 dark:text-gray-500">
|
||||
Claude needs your input
|
||||
</span>
|
||||
{q.header && (
|
||||
<span className="inline-flex items-center px-1.5 py-px rounded text-[9px] font-semibold uppercase tracking-wider bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border border-blue-100 dark:border-blue-800/50">
|
||||
{q.header}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step counter */}
|
||||
{!isSingle && (
|
||||
<span className="text-[10px] tabular-nums text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||
{currentStep + 1}/{total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress dots (multi-question) */}
|
||||
{!isSingle && (
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
{questions.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setCurrentStep(i)}
|
||||
className={`h-[3px] rounded-full transition-all duration-300 ${
|
||||
i === currentStep
|
||||
? 'w-5 bg-blue-500 dark:bg-blue-400'
|
||||
: i < currentStep
|
||||
? 'w-2.5 bg-blue-300 dark:bg-blue-600'
|
||||
: 'w-2.5 bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Question text */}
|
||||
<p className="text-[14px] leading-snug font-medium text-gray-900 dark:text-gray-100">
|
||||
{q.question}
|
||||
</p>
|
||||
{multi && (
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500">Select all that apply</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options — tight spacing */}
|
||||
<div className="px-4 pb-2 max-h-48 overflow-y-auto scrollbar-thin" role={multi ? 'group' : 'radiogroup'} aria-label={q.question}>
|
||||
<div className="space-y-1">
|
||||
{q.options.map((opt, optIdx) => {
|
||||
const isSelected = selected.has(opt.label);
|
||||
return (
|
||||
<button
|
||||
key={opt.label}
|
||||
type="button"
|
||||
onClick={() => toggleOption(currentStep, opt.label, multi)}
|
||||
className={`group w-full text-left flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-all duration-150 ${
|
||||
isSelected
|
||||
? 'border-blue-300 dark:border-blue-600 bg-blue-50/80 dark:bg-blue-900/25 ring-1 ring-blue-200/50 dark:ring-blue-700/30'
|
||||
: 'border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/60 dark:hover:bg-gray-750/50'
|
||||
}`}
|
||||
>
|
||||
{/* Keyboard hint */}
|
||||
<kbd className={`flex-shrink-0 w-5 h-5 rounded text-[10px] font-mono flex items-center justify-center transition-all duration-150 ${
|
||||
isSelected
|
||||
? 'bg-blue-500 dark:bg-blue-500 text-white font-semibold'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-700 group-hover:border-gray-300 dark:group-hover:border-gray-600'
|
||||
}`}>
|
||||
{optIdx + 1}
|
||||
</kbd>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-[13px] leading-tight transition-colors duration-150 ${
|
||||
isSelected
|
||||
? 'text-gray-900 dark:text-gray-100 font-medium'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{opt.label}
|
||||
</div>
|
||||
{opt.description && (
|
||||
<div className={`text-[11px] leading-snug transition-colors duration-150 ${
|
||||
isSelected
|
||||
? 'text-blue-600/70 dark:text-blue-300/70'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}>
|
||||
{opt.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection check */}
|
||||
{isSelected && (
|
||||
<svg className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* "Other" option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleOther(currentStep, multi)}
|
||||
className={`group w-full text-left flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-all duration-150 ${
|
||||
isOtherOn
|
||||
? 'border-blue-300 dark:border-blue-600 bg-blue-50/80 dark:bg-blue-900/25 ring-1 ring-blue-200/50 dark:ring-blue-700/30'
|
||||
: 'border-dashed border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/60 dark:hover:bg-gray-750/50'
|
||||
}`}
|
||||
>
|
||||
<kbd className={`flex-shrink-0 w-5 h-5 rounded text-[10px] font-mono flex items-center justify-center transition-all duration-150 ${
|
||||
isOtherOn
|
||||
? 'bg-blue-500 dark:bg-blue-500 text-white font-semibold'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-700 group-hover:border-gray-300 dark:group-hover:border-gray-600'
|
||||
}`}>
|
||||
0
|
||||
</kbd>
|
||||
<span className={`text-[13px] leading-tight transition-colors ${
|
||||
isOtherOn
|
||||
? 'text-gray-900 dark:text-gray-100 font-medium'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}>
|
||||
Other...
|
||||
</span>
|
||||
{isOtherOn && (
|
||||
<svg className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Other text input — inline */}
|
||||
{isOtherOn && (
|
||||
<div className="pl-[30px] pr-0.5">
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={otherInputRef}
|
||||
type="text"
|
||||
value={otherTexts.get(currentStep) || ''}
|
||||
onChange={(e) => setOtherText(currentStep, e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (isLast) handleSubmit();
|
||||
else setCurrentStep(s => s + 1);
|
||||
}
|
||||
// Prevent container keydown from firing
|
||||
e.stopPropagation();
|
||||
}}
|
||||
placeholder="Type your answer..."
|
||||
className="w-full text-[13px] rounded-lg border-0 bg-gray-50 dark:bg-gray-900/60 text-gray-900 dark:text-gray-100 px-3 py-1.5 outline-none ring-1 ring-gray-200 dark:ring-gray-700 focus:ring-2 focus:ring-blue-400 dark:focus:ring-blue-500 placeholder:text-gray-400 dark:placeholder:text-gray-600 transition-shadow duration-200"
|
||||
/>
|
||||
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] font-mono text-gray-300 dark:text-gray-600 bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded border border-gray-200 dark:border-gray-700">
|
||||
Enter
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer — compact */}
|
||||
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/50 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{isSingle ? 'Skip' : 'Skip all'}
|
||||
<span className="ml-1 text-[9px] text-gray-300 dark:text-gray-600">Esc</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!isSingle && !isFirst && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentStep(s => s - 1)}
|
||||
className="inline-flex items-center gap-0.5 text-[11px] font-medium px-2.5 py-1.5 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-all duration-150"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isLast ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!hasCurrentSelection && !Object.keys(buildAnswers()).length}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold px-3.5 py-1.5 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 dark:from-blue-500 dark:to-blue-600 text-white shadow-sm hover:shadow-md disabled:opacity-30 disabled:cursor-not-allowed disabled:shadow-none transition-all duration-200"
|
||||
>
|
||||
Submit
|
||||
<span className="text-[9px] opacity-70 font-mono ml-0.5">Enter</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentStep(s => s + 1)}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold px-3.5 py-1.5 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 dark:from-blue-500 dark:to-blue-600 text-white shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
Next
|
||||
<span className="text-[9px] opacity-70 font-mono ml-0.5">Enter</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { AskUserQuestionPanel } from './AskUserQuestionPanel';
|
||||
@@ -3,3 +3,4 @@ export { DiffViewer } from './DiffViewer';
|
||||
export { OneLineDisplay } from './OneLineDisplay';
|
||||
export { CollapsibleDisplay } from './CollapsibleDisplay';
|
||||
export * from './ContentRenderers';
|
||||
export * from './InteractiveRenderers';
|
||||
|
||||
25
src/components/chat/tools/configs/permissionPanelRegistry.ts
Normal file
25
src/components/chat/tools/configs/permissionPanelRegistry.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { PendingPermissionRequest } from '../../types/types';
|
||||
|
||||
export interface PermissionPanelProps {
|
||||
request: PendingPermissionRequest;
|
||||
onDecision: (
|
||||
requestIds: string | string[],
|
||||
decision: { allow?: boolean; message?: string; updatedInput?: unknown },
|
||||
) => void;
|
||||
}
|
||||
|
||||
const registry: Record<string, ComponentType<PermissionPanelProps>> = {};
|
||||
|
||||
export function registerPermissionPanel(
|
||||
toolName: string,
|
||||
component: ComponentType<PermissionPanelProps>,
|
||||
): void {
|
||||
registry[toolName] = component;
|
||||
}
|
||||
|
||||
export function getPermissionPanel(
|
||||
toolName: string,
|
||||
): ComponentType<PermissionPanelProps> | null {
|
||||
return registry[toolName] || null;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export interface ToolDisplayConfig {
|
||||
// Collapsible config
|
||||
title?: string | ((input: any) => string);
|
||||
defaultOpen?: boolean;
|
||||
contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task';
|
||||
contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task' | 'question-answer';
|
||||
getContentProps?: (input: any, helpers?: any) => any;
|
||||
actionButton?: 'file-button' | 'none';
|
||||
};
|
||||
@@ -35,7 +35,7 @@ export interface ToolDisplayConfig {
|
||||
title?: string | ((result: any) => string);
|
||||
defaultOpen?: boolean;
|
||||
// Special result handlers
|
||||
contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message' | 'task';
|
||||
contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message' | 'task' | 'question-answer';
|
||||
getMessage?: (result: any) => string;
|
||||
getContentProps?: (result: any) => any;
|
||||
};
|
||||
@@ -453,6 +453,34 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// INTERACTIVE TOOLS
|
||||
// ============================================================================
|
||||
|
||||
AskUserQuestion: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
title: (input: any) => {
|
||||
const count = input.questions?.length || 0;
|
||||
const hasAnswers = input.answers && Object.keys(input.answers).length > 0;
|
||||
if (count === 1) {
|
||||
const header = input.questions[0]?.header || 'Question';
|
||||
return hasAnswers ? `${header} — answered` : header;
|
||||
}
|
||||
return hasAnswers ? `${count} questions — answered` : `${count} questions`;
|
||||
},
|
||||
defaultOpen: true,
|
||||
contentType: 'question-answer',
|
||||
getContentProps: (input: any) => ({
|
||||
questions: input.questions || [],
|
||||
answers: input.answers || {}
|
||||
}),
|
||||
},
|
||||
result: {
|
||||
hideOnSuccess: true
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// PLAN TOOLS
|
||||
// ============================================================================
|
||||
|
||||
@@ -65,6 +65,18 @@ export interface PendingPermissionRequest {
|
||||
receivedAt?: Date;
|
||||
}
|
||||
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
question: string;
|
||||
header?: string;
|
||||
options: QuestionOption[];
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatInterfaceProps {
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
|
||||
@@ -402,11 +402,26 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
|
||||
content.startsWith('[Request interrupted');
|
||||
|
||||
if (!shouldSkip) {
|
||||
converted.push({
|
||||
type: 'user',
|
||||
content: unescapeWithMathProtection(content),
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
});
|
||||
// Parse <task-notification> blocks into compact system messages
|
||||
const taskNotifRegex = /<task-notification>\s*<task-id>[^<]*<\/task-id>\s*<output-file>[^<]*<\/output-file>\s*<status>([^<]*)<\/status>\s*<summary>([^<]*)<\/summary>\s*<\/task-notification>/g;
|
||||
const taskNotifMatch = taskNotifRegex.exec(content);
|
||||
if (taskNotifMatch) {
|
||||
const status = taskNotifMatch[1]?.trim() || 'completed';
|
||||
const summary = taskNotifMatch[2]?.trim() || 'Background task finished';
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: summary,
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
isTaskNotification: true,
|
||||
taskStatus: status,
|
||||
});
|
||||
} else {
|
||||
converted.push({
|
||||
type: 'user',
|
||||
content: unescapeWithMathProtection(content),
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -157,16 +157,23 @@ export default function ChatComposer({
|
||||
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
|
||||
};
|
||||
|
||||
// Detect if the AskUserQuestion interactive panel is active
|
||||
const hasQuestionPanel = pendingPermissionRequests.some(
|
||||
(r) => r.toolName === 'AskUserQuestion'
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6">
|
||||
<div className="flex-1">
|
||||
<ClaudeStatus
|
||||
status={claudeStatus}
|
||||
isLoading={isLoading}
|
||||
onAbort={onAbortSession}
|
||||
provider={provider}
|
||||
/>
|
||||
</div>
|
||||
{!hasQuestionPanel && (
|
||||
<div className="flex-1">
|
||||
<ClaudeStatus
|
||||
status={claudeStatus}
|
||||
isLoading={isLoading}
|
||||
onAbort={onAbortSession}
|
||||
provider={provider}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-4xl mx-auto mb-3">
|
||||
<PermissionRequestsBanner
|
||||
@@ -175,7 +182,7 @@ export default function ChatComposer({
|
||||
handleGrantToolPermission={handleGrantToolPermission}
|
||||
/>
|
||||
|
||||
<ChatInputControls
|
||||
{!hasQuestionPanel && <ChatInputControls
|
||||
permissionMode={permissionMode}
|
||||
onModeSwitch={onModeSwitch}
|
||||
provider={provider}
|
||||
@@ -189,10 +196,10 @@ export default function ChatComposer({
|
||||
isUserScrolledUp={isUserScrolledUp}
|
||||
hasMessages={hasMessages}
|
||||
onScrollToBottom={onScrollToBottom}
|
||||
/>
|
||||
/>}
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative max-w-4xl mx-auto">
|
||||
{!hasQuestionPanel && <form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative max-w-4xl mx-auto">
|
||||
{isDragActive && (
|
||||
<div className="absolute inset-0 bg-blue-500/20 border-2 border-dashed border-blue-500 rounded-lg flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-lg">
|
||||
@@ -340,7 +347,7 @@ export default function ChatComposer({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,6 +127,14 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : message.isTaskNotification ? (
|
||||
/* Compact task notification on the left */
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 ${message.taskStatus === 'completed' ? 'bg-green-400 dark:bg-green-500' : 'bg-amber-400 dark:bg-amber-500'}`} />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{message.content}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Claude/Error/Tool messages on the left */
|
||||
<div className="w-full">
|
||||
|
||||
@@ -2,6 +2,10 @@ import React from 'react';
|
||||
import type { PendingPermissionRequest } from '../../types/types';
|
||||
import { buildClaudeToolPermissionEntry, formatToolInputForDisplay } from '../../utils/chatPermissions';
|
||||
import { getClaudeSettings } from '../../utils/chatStorage';
|
||||
import { getPermissionPanel, registerPermissionPanel } from '../../tools/configs/permissionPanelRegistry';
|
||||
import { AskUserQuestionPanel } from '../../tools/components/InteractiveRenderers';
|
||||
|
||||
registerPermissionPanel('AskUserQuestion', AskUserQuestionPanel);
|
||||
|
||||
interface PermissionRequestsBannerProps {
|
||||
pendingPermissionRequests: PendingPermissionRequest[];
|
||||
@@ -24,6 +28,17 @@ export default function PermissionRequestsBanner({
|
||||
return (
|
||||
<div className="mb-3 space-y-2">
|
||||
{pendingPermissionRequests.map((request) => {
|
||||
const CustomPanel = getPermissionPanel(request.toolName);
|
||||
if (CustomPanel) {
|
||||
return (
|
||||
<CustomPanel
|
||||
key={request.requestId}
|
||||
request={request}
|
||||
onDecision={handlePermissionDecision}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const rawInput = formatToolInputForDisplay(request.input);
|
||||
const permissionEntry = buildClaudeToolPermissionEntry(request.toolName, rawInput);
|
||||
const settings = getClaudeSettings();
|
||||
|
||||
Reference in New Issue
Block a user