Browse Source

feat(desktop): Ask Question Tool Support (#8232)

Daniel Polito 1 month ago
parent
commit
3600bd27f4

+ 75 - 0
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"
@@ -49,6 +50,9 @@ type State = {
   permission: {
     [sessionID: string]: PermissionRequest[]
   }
+  question: {
+    [sessionID: string]: QuestionRequest[]
+  }
   mcp: {
     [name: string]: McpStatus
   }
@@ -98,6 +102,7 @@ function createGlobalSync() {
         session_diff: {},
         todo: {},
         permission: {},
+        question: {},
         mcp: {},
         lsp: [],
         vcs: undefined,
@@ -208,6 +213,38 @@ function createGlobalSync() {
               }
             })
           }),
+          sdk.question.list().then((x) => {
+            const grouped: Record<string, QuestionRequest[]> = {}
+            for (const question of x.data ?? []) {
+              if (!question?.id || !question.sessionID) continue
+              const existing = grouped[question.sessionID]
+              if (existing) {
+                existing.push(question)
+                continue
+              }
+              grouped[question.sessionID] = [question]
+            }
+
+            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")
         })
@@ -396,6 +433,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,

+ 8 - 0
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"
 
 export default function Layout(props: ParentProps) {
   const params = useParams()
@@ -27,6 +28,11 @@ export default function Layout(props: ParentProps) {
               response: "once" | "always" | "reject"
             }) => sdk.client.permission.respond(input)
 
+            const replyToQuestion = (input: { requestID: string; answers: QuestionAnswer[] }) =>
+              sdk.client.question.reply(input)
+
+            const rejectQuestion = (input: { requestID: string }) => sdk.client.question.reject(input)
+
             const navigateToSession = (sessionID: string) => {
               navigate(`/${params.dir}/session/${sessionID}`)
             }
@@ -36,6 +42,8 @@ export default function Layout(props: ParentProps) {
                 data={sync.data}
                 directory={directory()}
                 onPermissionRespond={respond}
+                onQuestionReply={replyToQuestion}
+                onQuestionReject={rejectQuestion}
                 onNavigateToSession={navigateToSession}
               >
                 <LocalProvider>{props.children}</LocalProvider>

+ 1 - 1
packages/opencode/src/tool/registry.ts

@@ -93,7 +93,7 @@ export namespace ToolRegistry {
 
     return [
       InvalidTool,
-      ...(Flag.OPENCODE_CLIENT === "cli" ? [QuestionTool] : []),
+      ...(["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) ? [QuestionTool] : []),
       BashTool,
       ReadTool,
       GlobTool,

+ 8 - 2
packages/ui/src/components/basic-tool.tsx

@@ -25,6 +25,7 @@ export interface BasicToolProps {
   hideDetails?: boolean
   defaultOpen?: boolean
   forceOpen?: boolean
+  locked?: boolean
   onSubtitleClick?: () => void
 }
 
@@ -35,8 +36,13 @@ export function BasicTool(props: BasicToolProps) {
     if (props.forceOpen) setOpen(true)
   })
 
+  const handleOpenChange = (value: boolean) => {
+    if (props.locked && !value) return
+    setOpen(value)
+  }
+
   return (
-    <Collapsible open={open()} onOpenChange={setOpen}>
+    <Collapsible open={open()} onOpenChange={handleOpenChange}>
       <Collapsible.Trigger>
         <div data-component="tool-trigger">
           <div data-slot="basic-tool-tool-trigger-content">
@@ -95,7 +101,7 @@ export function BasicTool(props: BasicToolProps) {
               </Switch>
             </div>
           </div>
-          <Show when={props.children && !props.hideDetails}>
+          <Show when={props.children && !props.hideDetails && !props.locked}>
             <Collapsible.Arrow />
           </Show>
         </div>

+ 192 - 1
packages/ui/src/components/message-part.css

@@ -405,7 +405,8 @@
 [data-component="tool-part-wrapper"] {
   width: 100%;
 
-  &[data-permission="true"] {
+  &[data-permission="true"],
+  &[data-question="true"] {
     position: sticky;
     top: calc(2px + var(--sticky-header-height, 40px));
     bottom: 0px;
@@ -490,3 +491,193 @@
     justify-content: flex-end;
   }
 }
+
+[data-component="question-prompt"] {
+  display: flex;
+  flex-direction: column;
+  padding: 12px;
+  background-color: var(--surface-inset-base);
+  border-radius: 0 0 6px 6px;
+  gap: 12px;
+
+  [data-slot="question-tabs"] {
+    display: flex;
+    gap: 4px;
+    flex-wrap: wrap;
+
+    [data-slot="question-tab"] {
+      padding: 4px 12px;
+      font-size: 13px;
+      border-radius: 4px;
+      background-color: var(--surface-base);
+      color: var(--text-base);
+      border: none;
+      cursor: pointer;
+      transition:
+        color 0.15s,
+        background-color 0.15s;
+
+      &:hover {
+        background-color: var(--surface-base-hover);
+      }
+
+      &[data-active="true"] {
+        background-color: var(--surface-raised-base);
+      }
+
+      &[data-answered="true"] {
+        color: var(--text-strong);
+      }
+    }
+  }
+
+  [data-slot="question-content"] {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+
+    [data-slot="question-text"] {
+      font-size: 14px;
+      color: var(--text-base);
+      line-height: 1.5;
+    }
+  }
+
+  [data-slot="question-options"] {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+
+    [data-slot="question-option"] {
+      display: flex;
+      flex-direction: column;
+      align-items: flex-start;
+      gap: 2px;
+      padding: 8px 12px;
+      background-color: var(--surface-base);
+      border: 1px solid var(--border-weaker-base);
+      border-radius: 6px;
+      cursor: pointer;
+      text-align: left;
+      width: 100%;
+      transition:
+        background-color 0.15s,
+        border-color 0.15s;
+      position: relative;
+
+      &:hover {
+        background-color: var(--surface-base-hover);
+        border-color: var(--border-default);
+      }
+
+      &[data-picked="true"] {
+        [data-component="icon"] {
+          position: absolute;
+          right: 12px;
+          top: 50%;
+          transform: translateY(-50%);
+          color: var(--text-strong);
+        }
+      }
+
+      [data-slot="option-label"] {
+        font-size: 14px;
+        color: var(--text-base);
+        font-weight: 500;
+      }
+
+      [data-slot="option-description"] {
+        font-size: 12px;
+        color: var(--text-weak);
+      }
+    }
+
+    [data-slot="custom-input-form"] {
+      display: flex;
+      gap: 8px;
+      padding: 8px 0;
+      align-items: stretch;
+
+      [data-slot="custom-input"] {
+        flex: 1;
+        padding: 8px 12px;
+        font-size: 14px;
+        border: 1px solid var(--border-default);
+        border-radius: 6px;
+        background-color: var(--surface-base);
+        color: var(--text-base);
+        outline: none;
+
+        &:focus {
+          border-color: var(--border-focus);
+        }
+
+        &::placeholder {
+          color: var(--text-weak);
+        }
+      }
+
+      [data-component="button"] {
+        height: auto;
+      }
+    }
+  }
+
+  [data-slot="question-review"] {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+
+    [data-slot="review-title"] {
+      display: none;
+    }
+
+    [data-slot="review-item"] {
+      display: flex;
+      flex-direction: column;
+      gap: 2px;
+      font-size: 13px;
+
+      [data-slot="review-label"] {
+        color: var(--text-weak);
+      }
+
+      [data-slot="review-value"] {
+        color: var(--text-strong);
+
+        &[data-answered="false"] {
+          color: var(--text-weak);
+        }
+      }
+    }
+  }
+
+  [data-slot="question-actions"] {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    justify-content: flex-end;
+  }
+}
+
+[data-component="question-answers"] {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  padding: 8px 12px;
+
+  [data-slot="question-answer-item"] {
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+    font-size: 13px;
+
+    [data-slot="question-text"] {
+      color: var(--text-weak);
+    }
+
+    [data-slot="answer-text"] {
+      color: var(--text-strong);
+    }
+  }
+}

+ 317 - 2
packages/ui/src/components/message-part.tsx

@@ -22,7 +22,11 @@ import {
   ToolPart,
   UserMessage,
   Todo,
+  QuestionRequest,
+  QuestionAnswer,
+  QuestionInfo,
 } from "@opencode-ai/sdk/v2"
+import { createStore } from "solid-js/store"
 import { useData } from "../context"
 import { useDiffComponent } from "../context/diff"
 import { useCodeComponent } from "../context/code"
@@ -238,6 +242,11 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
         icon: "checklist",
         title: "Read to-dos",
       }
+    case "question":
+      return {
+        icon: "bubble-5",
+        title: "Questions",
+      }
     default:
       return {
         icon: "mcp",
@@ -438,6 +447,7 @@ export interface ToolProps {
   hideDetails?: boolean
   defaultOpen?: boolean
   forceOpen?: boolean
+  locked?: boolean
 }
 
 export type ToolComponent = Component<ToolProps>
@@ -475,7 +485,15 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
     return next
   })
 
+  const questionRequest = createMemo(() => {
+    const next = data.store.question?.[props.message.sessionID]?.[0]
+    if (!next || !next.tool) return undefined
+    if (next.tool!.callID !== part.callID) return undefined
+    return next
+  })
+
   const [showPermission, setShowPermission] = createSignal(false)
+  const [showQuestion, setShowQuestion] = createSignal(false)
 
   createEffect(() => {
     const perm = permission()
@@ -487,9 +505,19 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
     }
   })
 
+  createEffect(() => {
+    const question = questionRequest()
+    if (question) {
+      const timeout = setTimeout(() => setShowQuestion(true), 50)
+      onCleanup(() => clearTimeout(timeout))
+    } else {
+      setShowQuestion(false)
+    }
+  })
+
   const [forceOpen, setForceOpen] = createSignal(false)
   createEffect(() => {
-    if (permission()) setForceOpen(true)
+    if (permission() || questionRequest()) setForceOpen(true)
   })
 
   const respond = (response: "once" | "always" | "reject") => {
@@ -512,7 +540,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
   const render = ToolRegistry.render(part.tool) ?? GenericTool
 
   return (
-    <div data-component="tool-part-wrapper" data-permission={showPermission()}>
+    <div data-component="tool-part-wrapper" data-permission={showPermission()} data-question={showQuestion()}>
       <Switch>
         <Match when={part.state.status === "error" && part.state.error}>
           {(error) => {
@@ -549,6 +577,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
             status={part.state.status}
             hideDetails={props.hideDetails}
             forceOpen={forceOpen()}
+            locked={showPermission() || showQuestion()}
             defaultOpen={props.defaultOpen}
           />
         </Match>
@@ -568,6 +597,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
           </div>
         </div>
       </Show>
+      <Show when={showQuestion() && questionRequest()}>{(request) => <QuestionPrompt request={request()} />}</Show>
     </div>
   )
 }
@@ -1042,3 +1072,288 @@ ToolRegistry.register({
     )
   },
 })
+
+ToolRegistry.register({
+  name: "question",
+  render(props) {
+    const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[])
+    const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[])
+    const completed = createMemo(() => answers().length > 0)
+
+    const subtitle = createMemo(() => {
+      const count = questions().length
+      if (count === 0) return ""
+      if (completed()) return `${count} answered`
+      return `${count} question${count > 1 ? "s" : ""}`
+    })
+
+    return (
+      <BasicTool
+        {...props}
+        defaultOpen={completed()}
+        icon="bubble-5"
+        trigger={{
+          title: "Questions",
+          subtitle: subtitle(),
+        }}
+      >
+        <Show when={completed()}>
+          <div data-component="question-answers">
+            <For each={questions()}>
+              {(q, i) => {
+                const answer = () => answers()[i()] ?? []
+                return (
+                  <div data-slot="question-answer-item">
+                    <div data-slot="question-text">{q.question}</div>
+                    <div data-slot="answer-text">{answer().join(", ") || "(no answer)"}</div>
+                  </div>
+                )
+              }}
+            </For>
+          </div>
+        </Show>
+      </BasicTool>
+    )
+  },
+})
+
+function QuestionPrompt(props: { request: QuestionRequest }) {
+  const data = useData()
+  const questions = createMemo(() => props.request.questions)
+  const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
+
+  const [store, setStore] = createStore({
+    tab: 0,
+    answers: [] as QuestionAnswer[],
+    custom: [] as string[],
+    editing: false,
+  })
+
+  const question = createMemo(() => questions()[store.tab])
+  const confirm = createMemo(() => !single() && store.tab === questions().length)
+  const options = createMemo(() => question()?.options ?? [])
+  const input = createMemo(() => store.custom[store.tab] ?? "")
+  const multi = createMemo(() => question()?.multiple === true)
+  const customPicked = createMemo(() => {
+    const value = input()
+    if (!value) return false
+    return store.answers[store.tab]?.includes(value) ?? false
+  })
+
+  function submit() {
+    const answers = questions().map((_, i) => store.answers[i] ?? [])
+    data.replyToQuestion?.({
+      requestID: props.request.id,
+      answers,
+    })
+  }
+
+  function reject() {
+    data.rejectQuestion?.({
+      requestID: props.request.id,
+    })
+  }
+
+  function pick(answer: string, custom: boolean = false) {
+    const answers = [...store.answers]
+    answers[store.tab] = [answer]
+    setStore("answers", answers)
+    if (custom) {
+      const inputs = [...store.custom]
+      inputs[store.tab] = answer
+      setStore("custom", inputs)
+    }
+    if (single()) {
+      data.replyToQuestion?.({
+        requestID: props.request.id,
+        answers: [[answer]],
+      })
+      return
+    }
+    setStore("tab", store.tab + 1)
+  }
+
+  function toggle(answer: string) {
+    const existing = store.answers[store.tab] ?? []
+    const next = [...existing]
+    const index = next.indexOf(answer)
+    if (index === -1) next.push(answer)
+    if (index !== -1) next.splice(index, 1)
+    const answers = [...store.answers]
+    answers[store.tab] = next
+    setStore("answers", answers)
+  }
+
+  function selectTab(index: number) {
+    setStore("tab", index)
+    setStore("editing", false)
+  }
+
+  function selectOption(optIndex: number) {
+    if (optIndex === options().length) {
+      setStore("editing", true)
+      return
+    }
+    const opt = options()[optIndex]
+    if (!opt) return
+    if (multi()) {
+      toggle(opt.label)
+      return
+    }
+    pick(opt.label)
+  }
+
+  function handleCustomSubmit(e: Event) {
+    e.preventDefault()
+    const value = input().trim()
+    if (!value) {
+      setStore("editing", false)
+      return
+    }
+    if (multi()) {
+      const existing = store.answers[store.tab] ?? []
+      const next = [...existing]
+      if (!next.includes(value)) next.push(value)
+      const answers = [...store.answers]
+      answers[store.tab] = next
+      setStore("answers", answers)
+      setStore("editing", false)
+      return
+    }
+    pick(value, true)
+    setStore("editing", false)
+  }
+
+  return (
+    <div data-component="question-prompt">
+      <Show when={!single()}>
+        <div data-slot="question-tabs">
+          <For each={questions()}>
+            {(q, index) => {
+              const active = () => index() === store.tab
+              const answered = () => (store.answers[index()]?.length ?? 0) > 0
+              return (
+                <button
+                  data-slot="question-tab"
+                  data-active={active()}
+                  data-answered={answered()}
+                  onClick={() => selectTab(index())}
+                >
+                  {q.header}
+                </button>
+              )
+            }}
+          </For>
+          <button data-slot="question-tab" data-active={confirm()} onClick={() => selectTab(questions().length)}>
+            Confirm
+          </button>
+        </div>
+      </Show>
+
+      <Show when={!confirm()}>
+        <div data-slot="question-content">
+          <div data-slot="question-text">
+            {question()?.question}
+            {multi() ? " (select all that apply)" : ""}
+          </div>
+          <div data-slot="question-options">
+            <For each={options()}>
+              {(opt, i) => {
+                const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
+                return (
+                  <button data-slot="question-option" data-picked={picked()} onClick={() => selectOption(i())}>
+                    <span data-slot="option-label">{opt.label}</span>
+                    <Show when={opt.description}>
+                      <span data-slot="option-description">{opt.description}</span>
+                    </Show>
+                    <Show when={picked()}>
+                      <Icon name="check-small" size="normal" />
+                    </Show>
+                  </button>
+                )
+              }}
+            </For>
+            <button
+              data-slot="question-option"
+              data-picked={customPicked()}
+              onClick={() => selectOption(options().length)}
+            >
+              <span data-slot="option-label">Type your own answer</span>
+              <Show when={!store.editing && input()}>
+                <span data-slot="option-description">{input()}</span>
+              </Show>
+              <Show when={customPicked()}>
+                <Icon name="check-small" size="normal" />
+              </Show>
+            </button>
+            <Show when={store.editing}>
+              <form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
+                <input
+                  ref={(el) => setTimeout(() => el.focus(), 0)}
+                  type="text"
+                  data-slot="custom-input"
+                  placeholder="Type your answer..."
+                  value={input()}
+                  onInput={(e) => {
+                    const inputs = [...store.custom]
+                    inputs[store.tab] = e.currentTarget.value
+                    setStore("custom", inputs)
+                  }}
+                />
+                <Button type="submit" variant="primary" size="small">
+                  {multi() ? "Add" : "Submit"}
+                </Button>
+                <Button type="button" variant="ghost" size="small" onClick={() => setStore("editing", false)}>
+                  Cancel
+                </Button>
+              </form>
+            </Show>
+          </div>
+        </div>
+      </Show>
+
+      <Show when={confirm()}>
+        <div data-slot="question-review">
+          <div data-slot="review-title">Review your answers</div>
+          <For each={questions()}>
+            {(q, index) => {
+              const value = () => store.answers[index()]?.join(", ") ?? ""
+              const answered = () => Boolean(value())
+              return (
+                <div data-slot="review-item">
+                  <span data-slot="review-label">{q.question}</span>
+                  <span data-slot="review-value" data-answered={answered()}>
+                    {answered() ? value() : "(not answered)"}
+                  </span>
+                </div>
+              )
+            }}
+          </For>
+        </div>
+      </Show>
+
+      <div data-slot="question-actions">
+        <Button variant="ghost" size="small" onClick={reject}>
+          Dismiss
+        </Button>
+        <Show when={!single()}>
+          <Show when={confirm()}>
+            <Button variant="primary" size="small" onClick={submit}>
+              Submit
+            </Button>
+          </Show>
+          <Show when={!confirm() && multi()}>
+            <Button
+              variant="secondary"
+              size="small"
+              onClick={() => selectTab(store.tab + 1)}
+              disabled={(store.answers[store.tab]?.length ?? 0) === 0}
+            >
+              Next
+            </Button>
+          </Show>
+        </Show>
+      </div>
+    </div>
+  )
+}

+ 21 - 1
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 QuestionReplyFn = (input: { requestID: string; answers: QuestionAnswer[] }) => void
+
+export type QuestionRejectFn = (input: { 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
+    onQuestionReply?: QuestionReplyFn
+    onQuestionReject?: QuestionRejectFn
     onNavigateToSession?: NavigateToSessionFn
   }) => {
     return {
@@ -48,6 +66,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
         return props.directory
       },
       respondToPermission: props.onPermissionRespond,
+      replyToQuestion: props.onQuestionReply,
+      rejectQuestion: props.onQuestionReject,
       navigateToSession: props.onNavigateToSession,
     }
   },