feat: added questions and permissions

This commit is contained in:
Bohdan Triapitsyn
2026-01-09 21:38:22 +02:00
parent 4db0d01fd9
commit 4a87126b74
18 changed files with 842 additions and 21 deletions
@@ -19,6 +19,7 @@ export const ChatContainer: React.FC = () => {
currentSessionId,
messages,
permissions,
questions,
streamingMessageIds,
isLoading,
loadMessages,
@@ -63,6 +64,14 @@ export const ChatContainer: React.FC = () => {
return currentSessionId ? permissions.get(currentSessionId) || [] : [];
}, [currentSessionId, permissions]);
const sessionQuestions = React.useMemo(() => {
return currentSessionId ? questions.get(currentSessionId) || [] : [];
}, [currentSessionId, questions]);
const sessionBlockingCards = React.useMemo(() => {
return [...sessionPermissions, ...sessionQuestions];
}, [sessionPermissions, sessionQuestions]);
const {
scrollRef,
handleMessageContentChange,
@@ -83,7 +92,7 @@ export const ChatContainer: React.FC = () => {
isSyncing,
isMobile,
messageStreamStates,
sessionPermissions,
sessionPermissions: sessionBlockingCards,
trimToViewportWindow,
sessionActivityPhase,
});
@@ -284,6 +293,7 @@ export const ChatContainer: React.FC = () => {
<MessageList
messages={sessionMessages}
permissions={sessionPermissions}
questions={sessionQuestions}
onMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
hasMoreAbove={hasMoreAbove}
@@ -3,7 +3,9 @@ import type { Message, Part } from '@opencode-ai/sdk/v2';
import ChatMessage from './ChatMessage';
import { PermissionCard } from './PermissionCard';
import { QuestionCard } from './QuestionCard';
import type { PermissionRequest } from '@/types/permission';
import type { QuestionRequest } from '@/types/question';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
import { filterSyntheticParts } from '@/lib/messages/synthetic';
import { useTurnGrouping } from './hooks/useTurnGrouping';
@@ -11,6 +13,7 @@ import { useTurnGrouping } from './hooks/useTurnGrouping';
interface MessageListProps {
messages: { info: Message; parts: Part[] }[];
permissions: PermissionRequest[];
questions: QuestionRequest[];
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
hasMoreAbove: boolean;
@@ -23,6 +26,7 @@ interface MessageListProps {
const MessageList: React.FC<MessageListProps> = ({
messages,
permissions,
questions,
onMessageContentChange,
getAnimationHandlers,
hasMoreAbove,
@@ -32,11 +36,11 @@ const MessageList: React.FC<MessageListProps> = ({
pendingAnchorId,
}) => {
React.useEffect(() => {
if (permissions.length === 0) {
if (permissions.length === 0 && questions.length === 0) {
return;
}
onMessageContentChange('permission');
}, [permissions, onMessageContentChange]);
}, [permissions, questions, onMessageContentChange]);
const displayMessages = React.useMemo(() => {
const seenIds = new Set<string>();
@@ -96,8 +100,11 @@ const MessageList: React.FC<MessageListProps> = ({
</div>
{permissions.length > 0 && (
{(questions.length > 0 || permissions.length > 0) && (
<div>
{questions.map((question) => (
<QuestionCard key={question.id} question={question} />
))}
{permissions.map((permission) => (
<PermissionCard key={permission.id} permission={permission} />
))}
@@ -0,0 +1,377 @@
import React from 'react';
import { RiCheckLine, RiCheckboxCircleFill, RiCircleLine, RiCloseLine, RiEditLine, RiListCheck3, RiQuestionLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { QuestionRequest } from '@/types/question';
import { useSessionStore } from '@/stores/useSessionStore';
interface QuestionCardProps {
question: QuestionRequest;
}
type TabKey = string;
const SUMMARY_TAB = 'summary';
export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
const { respondToQuestion, rejectQuestion } = useSessionStore();
const [activeTab, setActiveTab] = React.useState<TabKey>('0');
const [isResponding, setIsResponding] = React.useState(false);
const [hasResponded, setHasResponded] = React.useState(false);
const [selectedOptions, setSelectedOptions] = React.useState<Record<number, string[]>>({});
const [customMode, setCustomMode] = React.useState<Record<number, boolean>>({});
const [customText, setCustomText] = React.useState<Record<number, string>>({});
const questions = React.useMemo(() => question.questions ?? [], [question.questions]);
const isSummaryTab = activeTab === SUMMARY_TAB;
const activeIndex = isSummaryTab ? -1 : Math.max(0, Math.min(questions.length - 1, Number(activeTab) || 0));
const activeQuestion = isSummaryTab ? null : questions[activeIndex];
React.useEffect(() => {
setActiveTab('0');
setSelectedOptions({});
setCustomMode({});
setCustomText({});
setHasResponded(false);
}, [question.id]);
const tabs = React.useMemo(() => {
const questionTabs = questions.map((q, index) => ({
value: String(index),
label: q.header?.trim() || `Q${index + 1}`,
}));
// Add summary tab when multiple questions
if (questions.length > 1) {
questionTabs.push({ value: SUMMARY_TAB, label: 'Summary' });
}
return questionTabs;
}, [questions]);
// Helper to get answer display for a question index
const getAnswerDisplay = React.useCallback((index: number): string => {
const isCustom = Boolean(customMode[index]);
if (isCustom) {
const value = (customText[index] ?? '').trim();
return value || '(no answer)';
}
const answers = selectedOptions[index] ?? [];
return answers.length > 0 ? answers.join(', ') : '(no answer)';
}, [customMode, customText, selectedOptions]);
const isMultiple = Boolean(activeQuestion?.multiple);
const selectedForActive = selectedOptions[activeIndex] ?? [];
const isCustomActive = Boolean(customMode[activeIndex]);
const requiredSatisfied = React.useMemo(() => {
if (questions.length === 0) return false;
for (let index = 0; index < questions.length; index += 1) {
const isCustom = Boolean(customMode[index]);
if (isCustom) {
const value = (customText[index] ?? '').trim();
if (!value) return false;
continue;
}
const answers = selectedOptions[index] ?? [];
if (answers.length === 0) {
return false;
}
}
return true;
}, [customMode, customText, questions.length, selectedOptions]);
const buildAnswersPayload = React.useCallback((): string[][] => {
const answers: string[][] = [];
for (let index = 0; index < questions.length; index += 1) {
const isCustom = Boolean(customMode[index]);
if (isCustom) {
const value = (customText[index] ?? '').trim();
answers.push(value ? [value] : []);
continue;
}
answers.push(selectedOptions[index] ?? []);
}
return answers;
}, [customMode, customText, questions.length, selectedOptions]);
const handleToggleOption = React.useCallback(
(label: string) => {
if (!activeQuestion) return;
setCustomMode((prev) => ({ ...prev, [activeIndex]: false }));
setSelectedOptions((prev) => {
const current = prev[activeIndex] ?? [];
if (isMultiple) {
const exists = current.includes(label);
const next = exists ? current.filter((item) => item !== label) : [...current, label];
return { ...prev, [activeIndex]: next };
}
return { ...prev, [activeIndex]: [label] };
});
},
[activeIndex, activeQuestion, isMultiple]
);
const handleSelectCustom = React.useCallback(() => {
setCustomMode((prev) => ({ ...prev, [activeIndex]: true }));
setSelectedOptions((prev) => ({ ...prev, [activeIndex]: [] }));
}, [activeIndex]);
const handleConfirm = React.useCallback(async () => {
if (!requiredSatisfied) return;
setIsResponding(true);
try {
const answers = buildAnswersPayload();
await respondToQuestion(question.sessionID, question.id, answers);
setHasResponded(true);
} catch {
// ignored
} finally {
setIsResponding(false);
}
}, [buildAnswersPayload, question.id, question.sessionID, requiredSatisfied, respondToQuestion]);
const handleDismiss = React.useCallback(async () => {
setIsResponding(true);
try {
await rejectQuestion(question.sessionID, question.id);
setHasResponded(true);
} catch {
// ignored
} finally {
setIsResponding(false);
}
}, [question.id, question.sessionID, rejectQuestion]);
if (hasResponded || questions.length === 0) {
return null;
}
return (
<div className="group w-full pt-0 pb-2">
<div className="chat-column">
<div className="-mt-1 border border-border/30 rounded-xl bg-muted/10">
{/* Header */}
<div className="px-2 py-1.5 border-b border-border/20">
<div className="flex items-center gap-2">
<RiQuestionLine className="h-3.5 w-3.5 text-primary" />
<span className="typography-meta font-medium text-muted-foreground">Input needed</span>
</div>
</div>
<div className="px-2 py-2">
{/* Minimal inline tabs for multiple questions */}
{tabs.length > 1 ? (
<div className="flex items-center gap-1 mb-2 flex-wrap">
{tabs.map((tab) => {
const isActive = activeTab === tab.value;
const isSummary = tab.value === SUMMARY_TAB;
return (
<button
key={tab.value}
type="button"
onClick={() => setActiveTab(tab.value)}
className={cn(
'px-2 py-0.5 typography-meta font-medium rounded transition-colors flex items-center gap-1',
isActive
? 'bg-muted/40 text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/20'
)}
>
{isSummary ? <RiListCheck3 className="h-3 w-3" /> : null}
{tab.label}
</button>
);
})}
</div>
) : null}
{/* Summary view */}
{isSummaryTab ? (
<div className="space-y-2">
{questions.map((q, index) => {
const answer = getAnswerDisplay(index);
const hasAnswer = answer !== '(no answer)';
return (
<button
key={index}
type="button"
onClick={() => setActiveTab(String(index))}
className="w-full text-left rounded px-1.5 py-1 hover:bg-muted/20 transition-colors"
>
<div className="typography-micro text-muted-foreground">{q.header || `Question ${index + 1}`}</div>
<div className={cn(
'typography-meta',
hasAnswer ? 'text-foreground' : 'text-muted-foreground/50 italic'
)}>
{answer}
</div>
</button>
);
})}
</div>
) : activeQuestion ? (
<>
<div className="typography-meta font-medium text-foreground mb-1.5">{activeQuestion.question}</div>
{isMultiple ? (
<div className="typography-micro text-muted-foreground mb-1.5">Select multiple</div>
) : null}
<div className="space-y-0.5">
{activeQuestion.options.map((option, index) => {
const selected = selectedForActive.includes(option.label);
const recommended = /\(recommended\)/i.test(option.label);
return (
<button
key={`${index}:${option.label}`}
type="button"
onClick={() => handleToggleOption(option.label)}
disabled={isResponding}
className={cn(
'w-full px-1.5 py-1 text-left rounded transition-colors',
'hover:bg-muted/30',
selected ? 'bg-muted/20' : null,
isResponding ? 'opacity-60 cursor-not-allowed' : null
)}
>
<div className="flex items-start gap-2">
<div className="mt-0.5 shrink-0">
{selected ? (
<RiCheckboxCircleFill className="h-3.5 w-3.5 text-primary" />
) : (
<RiCircleLine className="h-3.5 w-3.5 text-muted-foreground/50" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className={cn(
'typography-meta break-all',
selected ? 'text-foreground font-medium' : 'text-foreground/80'
)}>
{option.label}
</span>
{recommended ? (
<span className="typography-micro text-primary/80">recommended</span>
) : null}
</div>
{option.description ? (
<div className="typography-micro text-muted-foreground break-words">{option.description}</div>
) : null}
</div>
</div>
</button>
);
})}
{/* Custom answer option */}
<button
type="button"
onClick={handleSelectCustom}
disabled={isResponding}
className={cn(
'w-full px-1.5 py-1 text-left rounded transition-colors',
'hover:bg-muted/30',
isCustomActive ? 'bg-muted/20' : null,
isResponding ? 'opacity-60 cursor-not-allowed' : null
)}
>
<div className="flex items-center gap-2">
<RiEditLine className={cn(
'h-3.5 w-3.5',
isCustomActive ? 'text-primary' : 'text-muted-foreground/50'
)} />
<span className={cn(
'typography-meta',
isCustomActive ? 'text-foreground font-medium' : 'text-muted-foreground'
)}>
Other
</span>
</div>
</button>
{isCustomActive ? (
<div className="pl-6 pr-1 pt-0.5">
<textarea
ref={(el) => {
if (el) {
el.style.height = 'auto';
const lineHeight = 20; // approx typography-meta line height
const minHeight = lineHeight * 2;
const maxHeight = lineHeight * 4;
el.style.height = `${Math.min(Math.max(el.scrollHeight, minHeight), maxHeight)}px`;
}
}}
value={customText[activeIndex] ?? ''}
onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => {
const el = event.target;
el.style.height = 'auto';
const lineHeight = 20;
const minHeight = lineHeight * 2;
const maxHeight = lineHeight * 4;
el.style.height = `${Math.min(Math.max(el.scrollHeight, minHeight), maxHeight)}px`;
setCustomText((prev) => ({ ...prev, [activeIndex]: el.value }));
}}
placeholder="Your answer"
disabled={isResponding}
rows={2}
className="w-full bg-transparent border border-border/30 focus:border-primary rounded px-2 py-1 outline-none typography-meta text-foreground placeholder:text-muted-foreground/50 transition-colors resize-none overflow-hidden"
autoFocus
/>
</div>
) : null}
</div>
</>
) : null}
</div>
{/* Footer actions */}
<div className="px-2 pb-1.5 pt-1 flex items-center gap-1.5 border-t border-border/20">
<button
type="button"
onClick={handleConfirm}
disabled={isResponding || !requiredSatisfied}
className={cn(
'flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded transition-colors',
'bg-[rgb(var(--status-success)/0.1)] text-[var(--status-success)] hover:bg-[rgb(var(--status-success)/0.2)]',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
<RiCheckLine className="h-3 w-3" />
Confirm
</button>
<button
type="button"
onClick={handleDismiss}
disabled={isResponding}
className={cn(
'flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded transition-colors',
'bg-[rgb(var(--status-error)/0.1)] text-[var(--status-error)] hover:bg-[rgb(var(--status-error)/0.2)]',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
<RiCloseLine className="h-3 w-3" />
Dismiss
</button>
{isResponding ? (
<div className="ml-auto">
<div className="animate-spin h-3 w-3 border border-primary border-t-transparent rounded-full" />
</div>
) : null}
</div>
</div>
</div>
</div>
);
};
@@ -1,7 +1,7 @@
import React from 'react';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiSurveyLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
@@ -86,6 +86,9 @@ export const getToolIcon = (toolName: string) => {
if (tool === 'skill') {
return <RiBookLine className={iconClass} />;
}
if (tool === 'question') {
return <RiSurveyLine className={iconClass} />;
}
if (tool.startsWith('git')) {
return <RiGitBranchLine className={iconClass} />;
}
@@ -147,11 +150,38 @@ const getRelativePath = (absolutePath: string, currentDirectory: string, isMobil
return absolutePath;
};
// Parse question tool output: "User has answered your questions: "Q1"="A1", "Q2"="A2". You can now..."
const parseQuestionOutput = (output: string): Array<{ question: string; answer: string }> | null => {
const match = output.match(/^User has answered your questions:\s*(.+?)\.\s*You can now/s);
if (!match) return null;
const pairs: Array<{ question: string; answer: string }> = [];
const content = match[1];
// Match "question"="answer" pairs, handling multiline answers
const pairRegex = /"([^"]+)"="([^"]*(?:[^"\\]|\\.)*)"/g;
let pairMatch;
while ((pairMatch = pairRegex.exec(content)) !== null) {
pairs.push({
question: pairMatch[1],
answer: pairMatch[2],
});
}
return pairs.length > 0 ? pairs : null;
};
const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile: boolean, currentDirectory: string): string => {
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
// Question tool: show "Asked N question(s)"
if (part.tool === 'question' && input?.questions && Array.isArray(input.questions)) {
const count = input.questions.length;
return `Asked ${count} question${count !== 1 ? 's' : ''}`;
}
if ((part.tool === 'edit' || part.tool === 'multiedit') && input) {
const filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
if (typeof filePath === 'string') {
@@ -593,6 +623,43 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
);
const renderResultContent = () => {
// Question tool: show parsed Q&A summary
if (part.tool === 'question') {
if (state.status === 'completed' && hasStringOutput) {
const parsedQA = parseQuestionOutput(outputString);
if (parsedQA && parsedQA.length > 0) {
return renderScrollableBlock(
<div className="space-y-2">
{parsedQA.map((qa, index) => (
<div key={index} className="space-y-0.5">
<div className="typography-micro text-muted-foreground">{qa.question}</div>
<div className="typography-meta text-foreground whitespace-pre-wrap">{qa.answer}</div>
</div>
))}
</div>,
{ maxHeightClass: 'max-h-[40vh]' }
);
}
}
if (state.status === 'error' && 'error' in state) {
return (
<div>
<div className="typography-meta font-medium text-muted-foreground mb-1">Error:</div>
<div className="typography-meta p-2 rounded-xl border" style={{
backgroundColor: 'var(--status-error-background)',
color: 'var(--status-error)',
borderColor: 'var(--status-error-border)',
}}>
{state.error}
</div>
</div>
);
}
return <div className="typography-meta text-muted-foreground">Awaiting response...</div>;
}
if (part.tool === 'todowrite' || part.tool === 'todoread') {
if (state.status === 'completed' && hasStringOutput) {
const todoContent = renderTodoOutput(outputString, { unstyled: true });
@@ -796,7 +863,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
hasNextTool ? 'before:bottom-[-0.6rem]' : 'before:bottom-0'
)}
>
{(part.tool === 'todowrite' || part.tool === 'todoread') ? (
{(part.tool === 'todowrite' || part.tool === 'todoread' || part.tool === 'question') ? (
renderResultContent()
) : (
<>