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
+9 -9
View File
@@ -11,7 +11,7 @@
"@heroui/system": "^2.4.23",
"@heroui/theme": "^2.4.23",
"@ibm/plex": "^6.4.1",
"@opencode-ai/sdk": "^1.1.6",
"@opencode-ai/sdk": "^1.1.8",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -74,7 +74,7 @@
},
"packages/desktop": {
"name": "@openchamber/desktop",
"version": "1.4.4",
"version": "1.4.5",
"dependencies": {
"@openchamber/ui": "workspace:*",
"@tauri-apps/plugin-notification": "^2.3.3",
@@ -97,7 +97,7 @@
},
"packages/ui": {
"name": "@openchamber/ui",
"version": "1.4.4",
"version": "1.4.5",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -105,7 +105,7 @@
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@opencode-ai/sdk": "^1.1.6",
"@opencode-ai/sdk": "^1.1.8",
"@pierre/diffs": "^1.0.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
@@ -169,10 +169,10 @@
},
"packages/vscode": {
"name": "openchamber",
"version": "1.4.4",
"version": "1.4.5",
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.1.6",
"@opencode-ai/sdk": "^1.1.8",
"jsonc-parser": "^3.3.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
@@ -190,7 +190,7 @@
},
"packages/web": {
"name": "@openchamber/web",
"version": "1.4.4",
"version": "1.4.5",
"bin": {
"openchamber": "./bin/cli.js",
},
@@ -198,7 +198,7 @@
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@opencode-ai/sdk": "^1.1.6",
"@opencode-ai/sdk": "^1.1.8",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -533,7 +533,7 @@
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.6", "", {}, "sha512-7Tiso9BExVgxz86VY6F807McCyOgu/SCaQJ87wwxxVSN8GpPpmUIYN5h6LH38EBNJWKXDjokasn/y9EkKxOisQ=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.8", "", {}, "sha512-N/02945tafTFN1FjlGCWO++dErtNyWaYOdARPifuL4nSsxTk2XtqZ2naZ28rj8kekjYR8JX1MA5upNEqwztOZA=="],
"@pierre/diffs": ["@pierre/diffs@1.0.2", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-RkFSDD5X/U+8QjyilPViYGJfmJNWXR17zTL8zw48+DcVC1Ujbh6I1edyuRnFfgRzpft05x2DSCkz2cjoIAxPvQ=="],
+1 -1
View File
@@ -64,7 +64,7 @@
"@heroui/system": "^2.4.23",
"@heroui/theme": "^2.4.23",
"@ibm/plex": "^6.4.1",
"@opencode-ai/sdk": "^1.1.6",
"@opencode-ai/sdk": "^1.1.8",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
+1 -1
View File
@@ -2858,7 +2858,7 @@ dependencies = [
[[package]]
name = "openchamber-desktop"
version = "1.4.4"
version = "1.4.5"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -17,7 +17,7 @@
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@opencode-ai/sdk": "^1.1.6",
"@opencode-ai/sdk": "^1.1.8",
"@pierre/diffs": "^1.0.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
@@ -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()
) : (
<>
@@ -229,6 +229,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
todowrite: 'updating todos',
todoread: 'reading todos',
skill: 'learning skill',
question: 'asking question',
};
const WORKING_PHRASES = [
+89 -1
View File
@@ -7,6 +7,8 @@ import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import type { Part, Session, Message } from '@opencode-ai/sdk/v2';
import type { PermissionRequest } from '@/types/permission';
import type { QuestionRequest } from '@/types/question';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
import { handleTodoUpdatedEvent } from '@/stores/useTodoStore';
import { useMcpStore } from '@/stores/useMcpStore';
@@ -90,6 +92,8 @@ export const useEventStream = () => {
updateMessageInfo,
updateSessionCompaction,
addPermission,
addQuestion,
dismissQuestion,
currentSessionId,
applySessionMetadata,
sessions,
@@ -149,12 +153,34 @@ export const useEventStream = () => {
}
};
const bootstrapPendingQuestions = async () => {
try {
const projects = useProjectsStore.getState().projects;
const projectDirs = projects.map((project) => project.path);
const sessionDirs = sessions.map((session) => (session as { directory?: string | null }).directory);
const directories = [effectiveDirectory, ...projectDirs, ...sessionDirs];
const pending = await opencodeClient.listPendingQuestions({ directories });
if (cancelled || pending.length === 0) {
return;
}
pending.forEach((request) => {
addQuestion(request as unknown as QuestionRequest);
});
} catch {
// ignored
}
};
void bootstrapPendingPermissions();
void bootstrapPendingQuestions();
return () => {
cancelled = true;
};
}, [addPermission]);
}, [addPermission, addQuestion, effectiveDirectory, sessions]);
const normalizeDirectory = React.useCallback((value: string | null | undefined): string | null => {
if (typeof value !== 'string') return null;
@@ -280,6 +306,7 @@ export const useEventStream = () => {
const resyncInFlightRef = React.useRef<Promise<void> | null>(null);
const lastResyncAtRef = React.useRef(0);
const permissionToastShownRef = React.useRef<Set<string>>(new Set());
const questionToastShownRef = React.useRef<Set<string>>(new Set());
const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => {
if (typeof document === 'undefined') return 'visible';
@@ -1134,6 +1161,65 @@ export const useEventStream = () => {
case 'permission.replied':
break;
case 'question.asked': {
if (!('sessionID' in props) || typeof props.sessionID !== 'string') {
break;
}
const request = props as unknown as QuestionRequest;
addQuestion(request);
const toastKey = `${request.sessionID}:${request.id}`;
if (!questionToastShownRef.current.has(toastKey)) {
setTimeout(() => {
const current = currentSessionIdRef.current;
if (current === request.sessionID) {
return;
}
const pending = useSessionStore
.getState()
.questions
.get(request.sessionID)
?.some((entry) => entry.id === request.id);
if (!pending) {
return;
}
questionToastShownRef.current.add(toastKey);
const sessionTitle =
useSessionStore.getState().sessions.find((s) => s.id === request.sessionID)?.title ||
'Session';
import('sonner').then(({ toast }) => {
toast.info('Input needed', {
description: sessionTitle,
action: {
label: 'Open',
onClick: () => {
useUIStore.getState().setActiveMainTab('chat');
void useSessionStore.getState().setCurrentSession(request.sessionID);
},
},
});
});
}, 0);
}
break;
}
case 'question.replied': {
const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null;
const requestId = typeof props.requestID === 'string' ? props.requestID : null;
if (sessionId && requestId) {
dismissQuestion(sessionId, requestId);
}
break;
}
case 'todo.updated': {
const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null;
const todos = Array.isArray(props.todos) ? props.todos : null;
@@ -1152,6 +1238,8 @@ export const useEventStream = () => {
completeStreamingMessage,
updateMessageInfo,
addPermission,
addQuestion,
dismissQuestion,
checkConnection,
requestSessionMetadataRefresh,
updateSessionCompaction,
+75
View File
@@ -14,6 +14,7 @@ import type {
Event,
} from "@opencode-ai/sdk/v2";
import type { PermissionRequest } from "@/types/permission";
import type { QuestionRequest } from "@/types/question";
type StreamEvent<TData> = {
data: TData;
event?: string;
@@ -782,6 +783,80 @@ class OpencodeService {
}
}
// Questions ("ask" tool)
async replyToQuestion(requestId: string, answers: string[] | string[][]): Promise<boolean> {
const normalizedAnswers: string[][] = (() => {
if (!Array.isArray(answers) || answers.length === 0) {
return [];
}
if (Array.isArray(answers[0])) {
return answers as string[][];
}
return [answers as string[]];
})();
const result = await this.client.question.reply({
requestID: requestId,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
answers: normalizedAnswers,
});
return result.data || false;
}
async rejectQuestion(requestId: string): Promise<boolean> {
const result = await this.client.question.reject({
requestID: requestId,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
});
return result.data || false;
}
async listPendingQuestions(options?: { directories?: Array<string | null | undefined> }): Promise<QuestionRequest[]> {
const fetches: Array<Promise<QuestionRequest[]>> = [];
const fetchForDirectory = async (directory?: string | null): Promise<QuestionRequest[]> => {
try {
const trimmed = typeof directory === 'string' ? directory.trim() : '';
const result = await this.client.question.list(trimmed ? { directory: trimmed } : undefined);
return (result.data || []) as unknown as QuestionRequest[];
} catch {
return [];
}
};
// Try unscoped first (server may return global pending items).
fetches.push(fetchForDirectory(null));
const uniqueDirectories = new Set<string>();
for (const entry of options?.directories ?? []) {
const normalized = this.normalizeCandidatePath(entry ?? null);
if (normalized) {
uniqueDirectories.add(normalized);
}
}
for (const directory of uniqueDirectories) {
fetches.push(fetchForDirectory(directory));
}
const results = await Promise.all(fetches);
const merged: QuestionRequest[] = [];
const seenIds = new Set<string>();
for (const list of results) {
for (const item of list) {
if (!item || typeof item !== 'object') continue;
const id = (item as { id?: unknown }).id;
if (typeof id !== 'string' || id.length === 0) continue;
if (seenIds.has(id)) continue;
seenIds.add(id);
merged.push(item);
}
}
return merged;
}
// Configuration
async getConfig(): Promise<Config> {
const response = await this.client.config.get();
+8
View File
@@ -156,6 +156,14 @@ export const TOOL_METADATA: Record<string, ToolMetadata> = {
inputFields: [
{ key: 'name', label: 'Skill Name', type: 'text' }
]
},
question: {
displayName: 'Question',
category: 'ai',
outputLanguage: 'text',
inputFields: [
{ key: 'questions', label: 'Questions', type: 'code', language: 'json' }
]
}
};
+123
View File
@@ -0,0 +1,123 @@
import { create } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { opencodeClient } from "@/lib/opencode/client";
import type { QuestionRequest } from "@/types/question";
import { getSafeStorage } from "./utils/safeStorage";
import { useSessionStore } from "./sessionStore";
interface QuestionState {
questions: Map<string, QuestionRequest[]>;
}
interface QuestionActions {
addQuestion: (question: QuestionRequest) => void;
dismissQuestion: (sessionId: string, requestId: string) => void;
respondToQuestion: (sessionId: string, requestId: string, answers: string[] | string[][]) => Promise<void>;
rejectQuestion: (sessionId: string, requestId: string) => Promise<void>;
}
type QuestionStore = QuestionState & QuestionActions;
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
const sanitizeQuestionEntries = (value: unknown): Array<[string, QuestionRequest[]]> => {
if (!Array.isArray(value)) {
return [];
}
const entries: Array<[string, QuestionRequest[]]> = [];
value.forEach((entry) => {
if (!Array.isArray(entry) || entry.length !== 2) {
return;
}
const [sessionId, questions] = entry;
if (typeof sessionId !== "string" || !Array.isArray(questions)) {
return;
}
entries.push([sessionId, questions as QuestionRequest[]]);
});
return entries;
};
const executeWithQuestionDirectory = async <T>(sessionId: string, operation: () => Promise<T>): Promise<T> => {
try {
const sessionStore = useSessionStore.getState();
const directory = sessionStore.getDirectoryForSession(sessionId);
if (directory) {
return opencodeClient.withDirectory(directory, operation);
}
} catch (error) {
console.warn("Failed to resolve session directory for question handling:", error);
}
return operation();
};
export const useQuestionStore = create<QuestionStore>()(
devtools(
persist(
(set, get) => ({
questions: new Map(),
addQuestion: (question: QuestionRequest) => {
const sessionId = question.sessionID;
if (!sessionId) {
return;
}
const existing = get().questions.get(sessionId);
if (existing?.some((entry) => entry.id === question.id)) {
return;
}
set((state) => {
const sessionQuestions = state.questions.get(sessionId) || [];
const next = new Map(state.questions);
next.set(sessionId, [...sessionQuestions, question]);
return { questions: next };
});
},
dismissQuestion: (sessionId: string, requestId: string) => {
if (!sessionId || !requestId) {
return;
}
set((state) => {
const sessionQuestions = state.questions.get(sessionId) || [];
const updated = sessionQuestions.filter((q) => q.id !== requestId);
const next = new Map(state.questions);
next.set(sessionId, updated);
return { questions: next };
});
},
respondToQuestion: async (sessionId: string, requestId: string, answers: string[] | string[][]) => {
await executeWithQuestionDirectory(sessionId, () => opencodeClient.replyToQuestion(requestId, answers));
get().dismissQuestion(sessionId, requestId);
},
rejectQuestion: async (sessionId: string, requestId: string) => {
await executeWithQuestionDirectory(sessionId, () => opencodeClient.rejectQuestion(requestId));
get().dismissQuestion(sessionId, requestId);
},
}),
{
name: "question-store",
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({
questions: Array.from(state.questions.entries()),
}),
merge: (persistedState, currentState) => {
if (!isRecord(persistedState)) {
return currentState;
}
const entries = sanitizeQuestionEntries(persistedState.questions);
return {
...currentState,
questions: new Map(entries),
};
},
}
),
{ name: "question-store" }
)
);
@@ -1,5 +1,6 @@
import type { Session, Message, Part } from "@opencode-ai/sdk/v2";
import type { PermissionRequest, PermissionResponse } from "@/types/permission";
import type { QuestionRequest } from "@/types/question";
export interface AttachedFile {
id: string;
@@ -78,6 +79,7 @@ export interface SessionStore {
messageStreamStates: Map<string, MessageStreamLifecycle>;
sessionCompactionUntil: Map<string, number>;
permissions: Map<string, PermissionRequest[]>;
questions: Map<string, QuestionRequest[]>;
sessionAbortFlags: Map<string, { timestamp: number; acknowledged: boolean }>;
attachedFiles: AttachedFile[];
abortPromptSessionId: string | null;
@@ -143,6 +145,12 @@ export interface SessionStore {
updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => void;
addPermission: (permission: PermissionRequest) => void;
respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => Promise<void>;
addQuestion: (question: QuestionRequest) => void;
dismissQuestion: (sessionId: string, requestId: string) => void;
respondToQuestion: (sessionId: string, requestId: string, answers: string[] | string[][]) => Promise<void>;
rejectQuestion: (sessionId: string, requestId: string) => Promise<void>;
clearError: () => void;
getSessionsByDirectory: (directory: string) => Session[];
getDirectoryForSession: (sessionId: string) => string | null;
+20
View File
@@ -3,6 +3,7 @@ import type { StoreApi, UseBoundStore } from "zustand";
import { devtools } from "zustand/middleware";
import type { Session, Message, Part } from "@opencode-ai/sdk/v2";
import type { PermissionRequest, PermissionResponse } from "@/types/permission";
import type { QuestionRequest } from "@/types/question";
import type { SessionStore, AttachedFile, EditPermissionMode } from "./types/sessionTypes";
import { ACTIVE_SESSION_WINDOW, MEMORY_LIMITS } from "./types/sessionTypes";
@@ -11,6 +12,7 @@ import { useMessageStore } from "./messageStore";
import { useFileStore } from "./fileStore";
import { useContextStore } from "./contextStore";
import { usePermissionStore } from "./permissionStore";
import { useQuestionStore } from "./questionStore";
import { opencodeClient } from "@/lib/opencode/client";
import { useDirectoryStore } from "./useDirectoryStore";
import { useConfigStore } from "./useConfigStore";
@@ -76,6 +78,7 @@ export const useSessionStore = create<SessionStore>()(
sessionCompactionUntil: new Map(),
sessionAbortFlags: new Map(),
permissions: new Map(),
questions: new Map(),
attachedFiles: [],
isLoading: false,
error: null,
@@ -461,6 +464,12 @@ export const useSessionStore = create<SessionStore>()(
return usePermissionStore.getState().addPermission(permission, contextData);
},
respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => usePermissionStore.getState().respondToPermission(sessionId, requestId, response),
addQuestion: (question: QuestionRequest) => useQuestionStore.getState().addQuestion(question),
dismissQuestion: (sessionId: string, requestId: string) => useQuestionStore.getState().dismissQuestion(sessionId, requestId),
respondToQuestion: (sessionId: string, requestId: string, answers: string[] | string[][]) => useQuestionStore.getState().respondToQuestion(sessionId, requestId, answers),
rejectQuestion: (sessionId: string, requestId: string) => useQuestionStore.getState().rejectQuestion(sessionId, requestId),
clearError: () => useSessionManagementStore.getState().clearError(),
getSessionsByDirectory: (directory: string) => useSessionManagementStore.getState().getSessionsByDirectory(directory),
getDirectoryForSession: (sessionId: string) => useSessionManagementStore.getState().getDirectoryForSession(sessionId),
@@ -876,6 +885,16 @@ usePermissionStore.subscribe((state, prevState) => {
});
});
useQuestionStore.subscribe((state, prevState) => {
if (state.questions === prevState.questions) {
return;
}
useSessionStore.setState({
questions: state.questions,
});
});
useDirectoryStore.subscribe((state, prevState) => {
const nextDirectory = normalizePath(state.currentDirectory ?? null);
const prevDirectory = normalizePath(prevState.currentDirectory ?? null);
@@ -924,6 +943,7 @@ useSessionStore.setState({
lastUsedProvider: useMessageStore.getState().lastUsedProvider,
isSyncing: useMessageStore.getState().isSyncing,
permissions: usePermissionStore.getState().permissions,
questions: useQuestionStore.getState().questions,
attachedFiles: useFileStore.getState().attachedFiles,
sessionModelSelections: useContextStore.getState().sessionModelSelections,
sessionAgentSelections: useContextStore.getState().sessionAgentSelections,
+37
View File
@@ -0,0 +1,37 @@
export type QuestionAnswer = string[];
export interface QuestionOption {
label: string;
description: string;
}
export interface QuestionInfo {
question: string;
header: string;
options: QuestionOption[];
multiple?: boolean;
}
export interface QuestionRequest {
id: string;
sessionID: string;
questions: QuestionInfo[];
tool?: {
messageID: string;
callID: string;
};
}
export interface QuestionAskedEvent {
type: 'question.asked';
properties: QuestionRequest;
}
export interface QuestionRepliedEvent {
type: 'question.replied';
properties: {
sessionID: string;
requestID: string;
answers: QuestionAnswer[];
};
}
+1 -1
View File
@@ -197,7 +197,7 @@
},
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.1.6",
"@opencode-ai/sdk": "^1.1.8",
"jsonc-parser": "^3.3.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
+1 -1
View File
@@ -26,7 +26,7 @@
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@opencode-ai/sdk": "^1.1.6",
"@opencode-ai/sdk": "^1.1.8",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",