diff --git a/bun.lock b/bun.lock index 303037d1..14a52a89 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 38823be5..beebe6b1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index c7529903..096de237 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2858,7 +2858,7 @@ dependencies = [ [[package]] name = "openchamber-desktop" -version = "1.4.4" +version = "1.4.5" dependencies = [ "anyhow", "axum", diff --git a/packages/ui/package.json b/packages/ui/package.json index 2ff7a2d2..84990d63 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 804a4398..6da194ec 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -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 = () => { void; getAnimationHandlers: (messageId: string) => AnimationHandlers; hasMoreAbove: boolean; @@ -23,6 +26,7 @@ interface MessageListProps { const MessageList: React.FC = ({ messages, permissions, + questions, onMessageContentChange, getAnimationHandlers, hasMoreAbove, @@ -32,11 +36,11 @@ const MessageList: React.FC = ({ 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(); @@ -96,8 +100,11 @@ const MessageList: React.FC = ({ - {permissions.length > 0 && ( + {(questions.length > 0 || permissions.length > 0) && (
+ {questions.map((question) => ( + + ))} {permissions.map((permission) => ( ))} diff --git a/packages/ui/src/components/chat/QuestionCard.tsx b/packages/ui/src/components/chat/QuestionCard.tsx new file mode 100644 index 00000000..2bfef6ce --- /dev/null +++ b/packages/ui/src/components/chat/QuestionCard.tsx @@ -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 = ({ question }) => { + const { respondToQuestion, rejectQuestion } = useSessionStore(); + const [activeTab, setActiveTab] = React.useState('0'); + const [isResponding, setIsResponding] = React.useState(false); + const [hasResponded, setHasResponded] = React.useState(false); + + const [selectedOptions, setSelectedOptions] = React.useState>({}); + const [customMode, setCustomMode] = React.useState>({}); + const [customText, setCustomText] = React.useState>({}); + + 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 ( +
+
+
+ {/* Header */} +
+
+ + Input needed +
+
+ +
+ {/* Minimal inline tabs for multiple questions */} + {tabs.length > 1 ? ( +
+ {tabs.map((tab) => { + const isActive = activeTab === tab.value; + const isSummary = tab.value === SUMMARY_TAB; + return ( + + ); + })} +
+ ) : null} + + {/* Summary view */} + {isSummaryTab ? ( +
+ {questions.map((q, index) => { + const answer = getAnswerDisplay(index); + const hasAnswer = answer !== '(no answer)'; + return ( + + ); + })} +
+ ) : activeQuestion ? ( + <> +
{activeQuestion.question}
+ + {isMultiple ? ( +
Select multiple
+ ) : null} + +
+ {activeQuestion.options.map((option, index) => { + const selected = selectedForActive.includes(option.label); + const recommended = /\(recommended\)/i.test(option.label); + + return ( + + ); + })} + + {/* Custom answer option */} + + + {isCustomActive ? ( +
+