diff --git a/AGENTS.md b/AGENTS.md index 87d59d4c923..cb233521534 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,3 +2,24 @@ - To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. - the default branch in this repo is `dev` + +## Testing the Web UI + +To test changes in the web UI (`packages/app` or `packages/ui`), you need to run **two servers**: + +1. **API Server** (in `packages/opencode`): + + ```bash + cd packages/opencode + bun dev -- serve --port 5555 + ``` + +2. **Vite Dev Server** (in `packages/app`): + ```bash + cd packages/app + bun dev + ``` + +Then open http://localhost:3000 (Vite dev server with HMR), NOT port 5555 (API only). + +The Vite dev server provides hot module reloading so code changes are reflected immediately. diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index a0656c5fc60..6d6285c7e68 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -16,6 +16,7 @@ import { type LspStatus, type VcsInfo, type PermissionRequest, + type QuestionRequest, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -48,6 +49,9 @@ type State = { permission: { [sessionID: string]: PermissionRequest[] } + question: { + [sessionID: string]: QuestionRequest[] + } mcp: { [name: string]: McpStatus } @@ -96,6 +100,7 @@ function createGlobalSync() { session_diff: {}, todo: {}, permission: {}, + question: {}, mcp: {}, lsp: [], vcs: undefined, @@ -205,6 +210,38 @@ function createGlobalSync() { } }) }), + sdk.question.list().then((x) => { + const grouped: Record = {} + for (const q of x.data ?? []) { + if (!q?.id || !q.sessionID) continue + const existing = grouped[q.sessionID] + if (existing) { + existing.push(q) + continue + } + grouped[q.sessionID] = [q] + } + + batch(() => { + for (const sessionID of Object.keys(store.question)) { + if (grouped[sessionID]) continue + setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + setStore( + "question", + sessionID, + reconcile( + questions + .filter((q) => !!q?.id) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)), + { key: "id" }, + ), + ) + } + }) + }), ]).then(() => { setStore("status", "complete") }) @@ -393,6 +430,44 @@ function createGlobalSync() { ) break } + case "question.asked": { + const sessionID = event.properties.sessionID + const questions = store.question[sessionID] + if (!questions) { + setStore("question", sessionID, [event.properties]) + break + } + + const result = Binary.search(questions, event.properties.id, (q) => q.id) + if (result.found) { + setStore("question", sessionID, result.index, reconcile(event.properties)) + break + } + + setStore( + "question", + sessionID, + produce((draft) => { + draft.splice(result.index, 0, event.properties) + }), + ) + break + } + case "question.replied": + case "question.rejected": { + const questions = store.question[event.properties.sessionID] + if (!questions) break + const result = Binary.search(questions, event.properties.requestID, (q) => q.id) + if (!result.found) break + setStore( + "question", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } case "lsp.updated": { const sdk = createOpencodeClient({ baseUrl: globalSDK.url, diff --git a/packages/app/src/index.css b/packages/app/src/index.css index e40f0842b15..e03923049bd 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -5,3 +5,15 @@ cursor: default; } } + +/* Style prompt input when question is attached above it */ +.question-attached { + border-radius: 0 0 var(--radius-md) var(--radius-md) !important; + box-shadow: + -1px 0 0 0 var(--border-base), + 1px 0 0 0 var(--border-base), + 0 1px 0 0 var(--border-base), + 0 1px 2px -1px rgba(19, 16, 16, 0.04), + 0 1px 2px 0 rgba(19, 16, 16, 0.06), + 0 1px 3px 0 rgba(19, 16, 16, 0.08) !important; +} diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 39124637c26..f80e8cf2739 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -7,6 +7,7 @@ import { LocalProvider } from "@/context/local" import { base64Decode } from "@opencode-ai/util/encode" import { DataProvider } from "@opencode-ai/ui/context" import { iife } from "@opencode-ai/util/iife" +import type { QuestionAnswer } from "@opencode-ai/sdk/v2/client" export default function Layout(props: ParentProps) { const params = useParams() @@ -21,12 +22,17 @@ export default function Layout(props: ParentProps) { {iife(() => { const sync = useSync() const sdk = useSDK() - const respond = (input: { + const respondToPermission = (input: { sessionID: string permissionID: string response: "once" | "always" | "reject" }) => sdk.client.permission.respond(input) + const respondToQuestion = (input: { requestID: string; answers: QuestionAnswer[] }) => + sdk.client.question.reply({ requestID: input.requestID, answers: input.answers }) + + const rejectQuestion = (requestID: string) => sdk.client.question.reject({ requestID }) + const navigateToSession = (sessionID: string) => { navigate(`/${params.dir}/session/${sessionID}`) } @@ -35,7 +41,9 @@ export default function Layout(props: ParentProps) { {props.children} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index ab6995d9214..9c681572af0 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -15,7 +15,7 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Tabs } from "@opencode-ai/ui/tabs" import { useCodeComponent } from "@opencode-ai/ui/context/code" -import { SessionTurn } from "@opencode-ai/ui/session-turn" +import { SessionTurn, QuestionPrompt } from "@opencode-ai/ui/session-turn" import { createAutoScroll } from "@opencode-ai/ui/hooks" import { SessionReview } from "@opencode-ai/ui/session-review" import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail" @@ -268,6 +268,23 @@ export default function Page() { const hasReview = createMemo(() => reviewCount() > 0) const revertMessageID = createMemo(() => info()?.revert?.messageID) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) + + // Get child sessions for question/permission aggregation (like TUI) + const children = createMemo(() => { + const parentID = info()?.parentID ?? info()?.id + if (!parentID) return [] + return sync.data.session + .filter((s) => s.parentID === parentID || s.id === parentID) + .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + }) + + // Get questions from all child sessions (only if not a child session itself) + const questions = createMemo(() => { + if (info()?.parentID) return [] + const result = children().flatMap((x) => sync.data.question?.[x.id] ?? []) + return result + }) + const nextQuestion = createMemo(() => (questions().length > 0 ? questions()[0] : undefined)) const messagesReady = createMemo(() => { const id = params.id if (!id) return true @@ -1212,17 +1229,36 @@ export default function Page() { - {/* Prompt input */} + {/* Prompt input and question prompt */}
(promptDock = el)} class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" >
+ {/* Question prompt - attached directly above the prompt input */} + + {(question) => ( + { + sdk.client.question.reply({ + requestID: question().id, + answers, + }) + }} + onReject={() => { + sdk.client.question.reject({ + requestID: question().id, + }) + }} + /> + )} + setStore("newSessionWorktree", "main")} + class={nextQuestion() ? "question-attached" : ""} />
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index eb76681ded4..15a5c44e11b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -93,7 +93,7 @@ export namespace ToolRegistry { return [ InvalidTool, - ...(Flag.OPENCODE_CLIENT === "cli" ? [QuestionTool] : []), + QuestionTool, BashTool, ReadTool, GlobTool, diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 9b7aa736437..2a43704e505 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -375,3 +375,189 @@ gap: 12px; } } + +/* Question prompt - appears as a popunder attached to the prompt input */ +[data-component="question-prompt"] { + width: 100%; + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border-radius: var(--radius-md) var(--radius-md) 0 0; + background-color: var(--surface-raised-stronger-non-alpha); + box-shadow: + -1px 0 0 0 var(--border-base), + 1px 0 0 0 var(--border-base), + 0 -1px 0 0 var(--border-base), + 0 -2px 8px -2px rgba(0, 0, 0, 0.1); + + [data-slot="question-header"] { + display: none; + } + + [data-slot="question-tabs"] { + display: flex; + flex-wrap: wrap; + gap: 2px; + margin-bottom: 2px; + } + + [data-slot="question-tab"] { + padding: 4px 10px; + border-radius: var(--radius-sm); + font-family: var(--font-family-sans); + font-size: 12px; + color: var(--text-weak); + background: transparent; + border: none; + cursor: pointer; + + &:hover { + color: var(--text-base); + background-color: var(--surface-raised-base-hover); + } + + &[data-active="true"] { + background-color: var(--surface-base); + color: var(--text-strong); + } + + &[data-answered="true"]:not([data-active="true"]) { + color: var(--text-base); + } + } + + [data-slot="question-confirm"] { + display: flex; + flex-direction: column; + gap: 2px; + } + + [data-slot="question-confirm-title"] { + font-size: 13px; + color: var(--text-base); + margin-bottom: 2px; + } + + [data-slot="question-confirm-item"] { + display: flex; + gap: 6px; + font-size: 12px; + } + + [data-slot="question-confirm-label"] { + color: var(--text-subtle); + } + + [data-slot="question-confirm-value"] { + color: var(--text-strong); + + &[data-answered="false"] { + color: var(--syntax-critical); + } + } + + [data-slot="question-item"] { + display: flex; + flex-direction: column; + gap: 4px; + } + + [data-slot="question-text"] { + font-size: 13px; + color: var(--text-base); + padding-left: 2px; + } + + [data-slot="question-label"] { + display: none; + } + + [data-slot="question-options"] { + display: flex; + flex-direction: column; + gap: 0; + } + + [data-slot="question-option"] { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 8px; + border-radius: var(--radius-sm); + background: transparent; + border: none; + cursor: pointer; + text-align: left; + width: 100%; + + &:hover { + background-color: var(--surface-raised-base-hover); + } + + &[data-selected="true"] { + background-color: var(--surface-base); + } + + [data-component="checkbox"] { + flex-shrink: 0; + } + } + + [data-slot="question-option-content"] { + display: flex; + flex-direction: column; + min-width: 0; + } + + [data-slot="question-option-label"] { + font-family: var(--font-family-sans); + font-size: 13px; + color: var(--text-strong); + } + + [data-slot="question-option-description"] { + font-family: var(--font-family-sans); + font-size: 12px; + color: var(--text-subtle); + line-height: 1.3; + } + + [data-slot="question-custom-input"] { + padding-left: 24px; + padding-top: 2px; + + input { + width: 100%; + padding: 5px 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-weak-base); + background-color: var(--surface-inset-base); + color: var(--text-strong); + font-family: var(--font-family-sans); + font-size: 12px; + outline: none; + + &::placeholder { + color: var(--text-subtle); + } + + &:focus { + border-color: var(--border-base); + } + } + } + + [data-slot="question-actions"] { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 6px; + } + + [data-slot="question-actions-left"], + [data-slot="question-actions-right"] { + display: flex; + gap: 4px; + } +} diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index f69d414be58..b89cb37c664 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -3,6 +3,8 @@ import { Message as MessageType, Part as PartType, type PermissionRequest, + type QuestionAnswer, + type QuestionRequest, TextPart, ToolPart, } from "@opencode-ai/sdk/v2/client" @@ -11,7 +13,7 @@ import { useDiffComponent } from "../context/diff" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Binary } from "@opencode-ai/util/binary" -import { createEffect, createMemo, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js" +import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js" import { createResizeObserver } from "@solid-primitives/resize-observer" import { DiffChanges } from "./diff-changes" import { Typewriter } from "./typewriter" @@ -28,6 +30,315 @@ import { Spinner } from "./spinner" import { createStore } from "solid-js/store" import { DateTime, DurationUnit, Interval } from "luxon" import { createAutoScroll } from "../hooks" +import { Checkbox } from "./checkbox" + +export function QuestionPrompt(props: { + question: QuestionRequest + onRespond: (answers: QuestionAnswer[]) => void + onReject: () => void +}) { + const questions = () => props.question.questions + // Single mode: only one question AND not multiple-select (immediate submit on selection) + const single = () => questions().length === 1 && questions()[0]?.multiple !== true + + const [activeTab, setActiveTab] = createSignal(0) + const [selections, setSelections] = createSignal>>(new Map()) + const [customInputs, setCustomInputs] = createSignal>(new Map()) + const [showCustom, setShowCustom] = createSignal>(new Set()) + + // Whether we're on the confirm tab (only for multi-question) + const isConfirmTab = () => !single() && activeTab() === questions().length + const currentQuestion = () => questions()[activeTab()] + + const toggleOption = (questionIndex: number, label: string, multiple: boolean) => { + setSelections((prev) => { + const newMap = new Map(prev) + const current = newMap.get(questionIndex) ?? new Set() + + if (multiple) { + const newSet = new Set(current) + if (newSet.has(label)) { + newSet.delete(label) + } else { + newSet.add(label) + } + newMap.set(questionIndex, newSet) + } else { + // For single select, clear custom input if selecting a predefined option + setCustomInputs((prev) => { + const newMap = new Map(prev) + newMap.delete(questionIndex) + return newMap + }) + if (current.has(label)) { + newMap.set(questionIndex, new Set()) + } else { + newMap.set(questionIndex, new Set([label])) + } + } + + return newMap + }) + } + + const toggleCustomInput = (questionIndex: number) => { + setShowCustom((prev) => { + const newSet = new Set(prev) + if (newSet.has(questionIndex)) { + newSet.delete(questionIndex) + // Clear custom input when hiding + setCustomInputs((prev) => { + const newMap = new Map(prev) + newMap.delete(questionIndex) + return newMap + }) + } else { + newSet.add(questionIndex) + } + return newSet + }) + } + + const setCustomInputValue = (questionIndex: number, value: string, multiple: boolean) => { + setCustomInputs((prev) => { + const newMap = new Map(prev) + newMap.set(questionIndex, value) + return newMap + }) + // For single select, clear other selections when typing custom + if (!multiple && value) { + setSelections((prev) => { + const newMap = new Map(prev) + newMap.set(questionIndex, new Set()) + return newMap + }) + } + } + + const isSelected = (questionIndex: number, label: string) => { + return selections().get(questionIndex)?.has(label) ?? false + } + + const getCustomInput = (questionIndex: number) => { + return customInputs().get(questionIndex) ?? "" + } + + const hasCustomInput = (questionIndex: number) => { + const input = customInputs().get(questionIndex) + return input !== undefined && input.trim().length > 0 + } + + const hasAnswer = (questionIndex: number) => { + const selected = selections().get(questionIndex) + return (selected && selected.size > 0) || hasCustomInput(questionIndex) + } + + const getAnswerText = (questionIndex: number) => { + const selected = selections().get(questionIndex) + const custom = customInputs().get(questionIndex)?.trim() + const parts: string[] = [] + if (selected) parts.push(...Array.from(selected)) + if (custom) parts.push(custom) + return parts.join(", ") + } + + const canSubmit = () => { + return questions().every((_, idx) => hasAnswer(idx)) + } + + const handleSubmit = () => { + const answers: QuestionAnswer[] = questions().map((q, idx) => { + const selected = selections().get(idx) ?? new Set() + const custom = customInputs().get(idx)?.trim() + const result = Array.from(selected) + if (custom) { + if (q.multiple) { + result.push(custom) + } else { + return [custom] + } + } + return result + }) + props.onRespond(answers) + } + + // For single-select single-question, submit immediately on selection + const handleSingleSelect = (label: string) => { + if (single()) { + props.onRespond([[label]]) + } else { + toggleOption(activeTab(), label, false) + // Auto-advance to next tab after selecting + if (activeTab() < questions().length) { + setActiveTab(activeTab() + 1) + } + } + } + + // Handle option click for multi-select or when in multi-question mode + const handleOptionClick = (questionIndex: number, label: string, multiple: boolean) => { + if (!multiple && single()) { + handleSingleSelect(label) + } else { + toggleOption(questionIndex, label, multiple) + } + } + + return ( +
+ {/* Tabs - show when there are multiple questions, otherwise show header */} + + Question +
+ } + > +
+ + {(q, index) => ( + + )} + + +
+ + + {/* Question content - show current question or confirm */} + +
Review your answers
+ + {(q, index) => ( +
+ {q.header}: + + {hasAnswer(index()) ? getAnswerText(index()) : "(not answered)"} + +
+ )} +
+
+ } + > + + {(q) => ( +
+
+ + {q().header} + + + {q().question} + {q().multiple ? " (select all that apply)" : ""} + +
+
+ + {(option) => ( + + )} + + {/* Custom input option */} + + +
+ setCustomInputValue(activeTab(), e.currentTarget.value, q().multiple ?? false)} + autofocus + /> +
+
+
+
+ )} +
+ + +
+
+ +
+
+ 0}> + + + + Submit + + } + > + setActiveTab(activeTab() + 1)}> + Next + + } + > + + + +
+
+ + ) +} function computeStatusFromPart(part: PartType | undefined): string | undefined { if (!part) return undefined @@ -134,6 +445,7 @@ export function SessionTurn( const emptyAssistant: AssistantMessage[] = [] const emptyPermissions: PermissionRequest[] = [] const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = [] + const emptyQuestions: QuestionRequest[] = [] const idle = { type: "idle" as const } const allMessages = createMemo(() => data.store.message[props.sessionID] ?? emptyMessages) @@ -227,10 +539,34 @@ export function SessionTurn( return false }) - const permissions = createMemo(() => data.store.permission?.[props.sessionID] ?? emptyPermissions) + // Get current session to check if it's a child session + const session = createMemo(() => data.store.session.find((s) => s.id === props.sessionID)) + + // Get all child sessions (sessions that share the same parent, or are the parent) + const children = createMemo(() => { + const parentID = session()?.parentID ?? session()?.id + if (!parentID) return [] + return data.store.session + .filter((s) => s.parentID === parentID || s.id === parentID) + .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + }) + + // Get permissions from all child sessions (only if not a child session itself) + const permissions = createMemo(() => { + if (session()?.parentID) return emptyPermissions + return children().flatMap((x) => data.store.permission?.[x.id] ?? []) + }) const permissionCount = createMemo(() => permissions().length) const nextPermission = createMemo(() => permissions()[0]) + // Get questions from all child sessions (only if not a child session itself) + const questions = createMemo(() => { + if (session()?.parentID) return emptyQuestions + return children().flatMap((x) => data.store.question?.[x.id] ?? []) + }) + const questionCount = createMemo(() => questions().length) + const nextQuestion = createMemo(() => questions()[0]) + const permissionParts = createMemo(() => { if (props.stepsExpanded) return emptyPermissionParts @@ -553,6 +889,7 @@ export function SessionTurn( + {/* Response */}
diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index acab99fe8f6..4a15d2871ca 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -1,4 +1,13 @@ -import type { Message, Session, Part, FileDiff, SessionStatus, PermissionRequest } from "@opencode-ai/sdk/v2" +import type { + Message, + Session, + Part, + FileDiff, + SessionStatus, + PermissionRequest, + QuestionRequest, + QuestionAnswer, +} from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -16,6 +25,9 @@ type Data = { permission?: { [sessionID: string]: PermissionRequest[] } + question?: { + [sessionID: string]: QuestionRequest[] + } message: { [sessionID: string]: Message[] } @@ -30,6 +42,10 @@ export type PermissionRespondFn = (input: { response: "once" | "always" | "reject" }) => void +export type QuestionRespondFn = (input: { requestID: string; answers: QuestionAnswer[] }) => void + +export type QuestionRejectFn = (requestID: string) => void + export type NavigateToSessionFn = (sessionID: string) => void export const { use: useData, provider: DataProvider } = createSimpleContext({ @@ -38,6 +54,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ data: Data directory: string onPermissionRespond?: PermissionRespondFn + onQuestionRespond?: QuestionRespondFn + onQuestionReject?: QuestionRejectFn onNavigateToSession?: NavigateToSessionFn }) => { return { @@ -48,6 +66,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return props.directory }, respondToPermission: props.onPermissionRespond, + respondToQuestion: props.onQuestionRespond, + rejectQuestion: props.onQuestionReject, navigateToSession: props.onNavigateToSession, } }, diff --git a/packages/web/src/components/share/part.module.css b/packages/web/src/components/share/part.module.css index b1269445f66..bfb90d44485 100644 --- a/packages/web/src/components/share/part.module.css +++ b/packages/web/src/components/share/part.module.css @@ -425,4 +425,86 @@ color: var(--sl-color-text-secondary); } } + + [data-component="questions"] { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: var(--md-tool-width); + } + + [data-component="question-item"] { + border: 1px solid var(--sl-color-divider); + border-radius: 0.375rem; + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + + [data-slot="header"] { + font-size: 0.625rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--sl-color-text-dimmed); + font-weight: 500; + } + + [data-slot="question"] { + font-size: 0.875rem; + line-height: 1.4; + color: var(--sl-color-text); + } + + [data-slot="options"] { + display: flex; + flex-direction: column; + gap: 0.375rem; + margin-top: 0.25rem; + } + + [data-slot="option"] { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0.5rem 0.625rem; + border: 1px solid var(--sl-color-divider); + border-radius: 0.25rem; + font-size: 0.75rem; + + &[data-selected] { + border-color: var(--sl-color-green); + background: var(--sl-color-green-low); + } + + [data-slot="label"] { + font-weight: 500; + color: var(--sl-color-text); + } + + [data-slot="description"] { + color: var(--sl-color-text-secondary); + line-height: 1.4; + } + } + + [data-slot="answer"] { + display: flex; + align-items: baseline; + gap: 0.375rem; + margin-top: 0.25rem; + padding-top: 0.5rem; + border-top: 1px solid var(--sl-color-divider); + font-size: 0.75rem; + + [data-slot="answer-label"] { + color: var(--sl-color-text-dimmed); + font-weight: 500; + } + + [data-slot="answer-value"] { + color: var(--sl-color-green); + font-weight: 500; + } + } + } } diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx index f7a6a9304d4..74ae9bb90f8 100644 --- a/packages/web/src/components/share/part.tsx +++ b/packages/web/src/components/share/part.tsx @@ -18,6 +18,7 @@ import { IconRectangleStack, IconMagnifyingGlass, IconDocumentMagnifyingGlass, + IconQuestionMarkCircle, } from "../icons" import { IconMeta, IconRobot, IconOpenAI, IconGemini, IconAnthropic, IconBrain } from "../icons/custom" import { ContentCode } from "./content-code" @@ -119,6 +120,9 @@ export function Part(props: PartProps) { + + + @@ -278,6 +282,14 @@ export function Part(props: PartProps) { state={props.part.state} /> + + + (props.state.input?.questions ?? []) as QuestionInfo[] + const answers = () => (props.state.metadata?.answers ?? []) as string[][] + + return ( + <> +
+ Question + {questions().length === 1 ? "1 question" : `${questions().length} questions`} +
+
+ + {(q, i) => ( +
+
{q.header}
+
{q.question}
+
+ + {(opt) => { + const isSelected = () => answers()[i()]?.includes(opt.label) + return ( +
+ {opt.label} + {opt.description} +
+ ) + }} +
+
+ +
+ Answer: + {answers()[i()]?.join(", ")} +
+
+
+ )} +
+
+ + ) +} + export function FallbackTool(props: ToolProps) { return ( <>