Просмотр исходного кода

fix(app): hide prompt input when there are perms or questions (#12339)

Adam 2 недель назад
Родитель
Сommit
d7c2d5db3b

+ 295 - 0
packages/app/src/components/question-dock.tsx

@@ -0,0 +1,295 @@
+import { For, Show, createMemo, type Component } from "solid-js"
+import { createStore } from "solid-js/store"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { showToast } from "@opencode-ai/ui/toast"
+import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
+import { useLanguage } from "@/context/language"
+import { useSDK } from "@/context/sdk"
+
+export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
+  const sdk = useSDK()
+  const language = useLanguage()
+
+  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,
+    sending: 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
+  })
+
+  const fail = (err: unknown) => {
+    const message = err instanceof Error ? err.message : String(err)
+    showToast({ title: language.t("common.requestFailed"), description: message })
+  }
+
+  const reply = (answers: QuestionAnswer[]) => {
+    if (store.sending) return
+
+    setStore("sending", true)
+    sdk.client.question
+      .reply({ requestID: props.request.id, answers })
+      .catch(fail)
+      .finally(() => setStore("sending", false))
+  }
+
+  const reject = () => {
+    if (store.sending) return
+
+    setStore("sending", true)
+    sdk.client.question
+      .reject({ requestID: props.request.id })
+      .catch(fail)
+      .finally(() => setStore("sending", false))
+  }
+
+  const submit = () => {
+    reply(questions().map((_, i) => store.answers[i] ?? []))
+  }
+
+  const 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()) {
+      reply([[answer]])
+      return
+    }
+
+    setStore("tab", store.tab + 1)
+  }
+
+  const 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)
+  }
+
+  const selectTab = (index: number) => {
+    setStore("tab", index)
+    setStore("editing", false)
+  }
+
+  const selectOption = (optIndex: number) => {
+    if (store.sending) return
+
+    if (optIndex === options().length) {
+      setStore("editing", true)
+      return
+    }
+
+    const opt = options()[optIndex]
+    if (!opt) return
+    if (multi()) {
+      toggle(opt.label)
+      return
+    }
+    pick(opt.label)
+  }
+
+  const handleCustomSubmit = (e: Event) => {
+    e.preventDefault()
+    if (store.sending) return
+
+    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()}
+                  disabled={store.sending}
+                  onClick={() => selectTab(index())}
+                >
+                  {q.header}
+                </button>
+              )
+            }}
+          </For>
+          <button
+            data-slot="question-tab"
+            data-active={confirm()}
+            disabled={store.sending}
+            onClick={() => selectTab(questions().length)}
+          >
+            {language.t("ui.common.confirm")}
+          </button>
+        </div>
+      </Show>
+
+      <Show when={!confirm()}>
+        <div data-slot="question-content">
+          <div data-slot="question-text">
+            {question()?.question}
+            {multi() ? " " + language.t("ui.question.multiHint") : ""}
+          </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()}
+                    disabled={store.sending}
+                    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()}
+              disabled={store.sending}
+              onClick={() => selectOption(options().length)}
+            >
+              <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</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={language.t("ui.question.custom.placeholder")}
+                  value={input()}
+                  disabled={store.sending}
+                  onInput={(e) => {
+                    const inputs = [...store.custom]
+                    inputs[store.tab] = e.currentTarget.value
+                    setStore("custom", inputs)
+                  }}
+                />
+                <Button type="submit" variant="primary" size="small" disabled={store.sending}>
+                  {multi() ? language.t("ui.common.add") : language.t("ui.common.submit")}
+                </Button>
+                <Button
+                  type="button"
+                  variant="ghost"
+                  size="small"
+                  disabled={store.sending}
+                  onClick={() => setStore("editing", false)}
+                >
+                  {language.t("ui.common.cancel")}
+                </Button>
+              </form>
+            </Show>
+          </div>
+        </div>
+      </Show>
+
+      <Show when={confirm()}>
+        <div data-slot="question-review">
+          <div data-slot="review-title">{language.t("ui.messagePart.review.title")}</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() : language.t("ui.question.review.notAnswered")}
+                  </span>
+                </div>
+              )
+            }}
+          </For>
+        </div>
+      </Show>
+
+      <div data-slot="question-actions">
+        <Button variant="ghost" size="small" onClick={reject} disabled={store.sending}>
+          {language.t("ui.common.dismiss")}
+        </Button>
+        <Show when={!single()}>
+          <Show when={confirm()}>
+            <Button variant="primary" size="small" onClick={submit} disabled={store.sending}>
+              {language.t("ui.common.submit")}
+            </Button>
+          </Show>
+          <Show when={!confirm() && multi()}>
+            <Button
+              variant="secondary"
+              size="small"
+              onClick={() => selectTab(store.tab + 1)}
+              disabled={store.sending || (store.answers[store.tab]?.length ?? 0) === 0}
+            >
+              {language.t("ui.common.next")}
+            </Button>
+          </Show>
+        </Show>
+      </div>
+    </div>
+  )
+}

+ 60 - 27
packages/app/src/pages/session.tsx

@@ -36,6 +36,7 @@ import { BasicTool } from "@opencode-ai/ui/basic-tool"
 import { createAutoScroll } from "@opencode-ai/ui/hooks"
 import { SessionReview } from "@opencode-ai/ui/session-review"
 import { Mark } from "@opencode-ai/ui/logo"
+import { QuestionDock } from "@/components/question-dock"
 
 import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
 import type { DragEvent } from "@thisbeyond/solid-dnd"
@@ -270,15 +271,20 @@ export default function Page() {
   const comments = useComments()
   const permission = usePermission()
 
-  const request = createMemo(() => {
+  const permRequest = createMemo(() => {
     const sessionID = params.id
     if (!sessionID) return
-    const next = sync.data.permission[sessionID]?.[0]
-    if (!next) return
-    if (next.tool) return
-    return next
+    return sync.data.permission[sessionID]?.[0]
+  })
+
+  const questionRequest = createMemo(() => {
+    const sessionID = params.id
+    if (!sessionID) return
+    return sync.data.question[sessionID]?.[0]
   })
 
+  const blocked = createMemo(() => !!permRequest() || !!questionRequest())
+
   const [ui, setUi] = createStore({
     responding: false,
     pendingMessage: undefined as string | undefined,
@@ -292,14 +298,14 @@ export default function Page() {
 
   createEffect(
     on(
-      () => request()?.id,
+      () => permRequest()?.id,
       () => setUi("responding", false),
       { defer: true },
     ),
   )
 
   const decide = (response: "once" | "always" | "reject") => {
-    const perm = request()
+    const perm = permRequest()
     if (!perm) return
     if (ui.responding) return
 
@@ -1351,6 +1357,7 @@ export default function Page() {
     }
 
     if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
+      if (blocked()) return
       inputRef?.focus()
     }
   }
@@ -2693,7 +2700,31 @@ export default function Page() {
                 "md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(),
               }}
             >
-              <Show when={request()} keyed>
+              <Show when={questionRequest()} keyed>
+                {(req) => {
+                  const count = req.questions.length
+                  const subtitle =
+                    count === 0
+                      ? ""
+                      : `${count} ${language.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}`
+                  return (
+                    <div data-component="tool-part-wrapper" data-question="true" class="mb-3">
+                      <BasicTool
+                        icon="bubble-5"
+                        locked
+                        defaultOpen
+                        trigger={{
+                          title: language.t("ui.tool.questions"),
+                          subtitle,
+                        }}
+                      />
+                      <QuestionDock request={req} />
+                    </div>
+                  )
+                }}
+              </Show>
+
+              <Show when={permRequest()} keyed>
                 {(perm) => (
                   <div data-component="tool-part-wrapper" data-permission="true" class="mb-3">
                     <BasicTool
@@ -2743,25 +2774,27 @@ export default function Page() {
                 )}
               </Show>
 
-              <Show
-                when={prompt.ready()}
-                fallback={
-                  <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
-                    {handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
-                  </div>
-                }
-              >
-                <PromptInput
-                  ref={(el) => {
-                    inputRef = el
-                  }}
-                  newSessionWorktree={newSessionWorktree()}
-                  onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
-                  onSubmit={() => {
-                    comments.clear()
-                    resumeScroll()
-                  }}
-                />
+              <Show when={!blocked()}>
+                <Show
+                  when={prompt.ready()}
+                  fallback={
+                    <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
+                      {handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
+                    </div>
+                  }
+                >
+                  <PromptInput
+                    ref={(el) => {
+                      inputRef = el
+                    }}
+                    newSessionWorktree={newSessionWorktree()}
+                    onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
+                    onSubmit={() => {
+                      comments.clear()
+                      resumeScroll()
+                    }}
+                  />
+                </Show>
               </Show>
             </div>
           </div>

+ 23 - 66
packages/ui/src/components/session-turn.tsx

@@ -10,7 +10,6 @@ import {
 } from "@opencode-ai/sdk/v2/client"
 import { useData } from "../context"
 import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n"
-import { findLast } from "@opencode-ai/util/array"
 
 import { Binary } from "@opencode-ai/util/binary"
 import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
@@ -84,6 +83,7 @@ function AssistantMessageItem(props: {
   responsePartId: string | undefined
   hideResponsePart: boolean
   hideReasoning: boolean
+  hidden?: () => readonly { messageID: string; callID: string }[]
 }) {
   const data = useData()
   const emptyParts: PartType[] = []
@@ -104,13 +104,22 @@ function AssistantMessageItem(props: {
       parts = parts.filter((part) => part?.type !== "reasoning")
     }
 
-    if (!props.hideResponsePart) return parts
+    if (props.hideResponsePart) {
+      const responsePartId = props.responsePartId
+      if (responsePartId && responsePartId === lastTextPart()?.id) {
+        parts = parts.filter((part) => part?.id !== responsePartId)
+      }
+    }
 
-    const responsePartId = props.responsePartId
-    if (!responsePartId) return parts
-    if (responsePartId !== lastTextPart()?.id) return parts
+    const hidden = props.hidden?.() ?? []
+    if (hidden.length === 0) return parts
 
-    return parts.filter((part) => part?.id !== responsePartId)
+    const id = props.message.id
+    return parts.filter((part) => {
+      if (part?.type !== "tool") return true
+      const tool = part as ToolPart
+      return !hidden.some((h) => h.messageID === id && h.callID === tool.callID)
+    })
   })
 
   return <Message message={props.message} parts={filteredParts()} />
@@ -140,7 +149,6 @@ export function SessionTurn(
   const emptyFiles: FilePart[] = []
   const emptyAssistant: AssistantMessage[] = []
   const emptyPermissions: PermissionRequest[] = []
-  const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = []
   const emptyQuestions: QuestionRequest[] = []
   const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = []
   const idle = { type: "idle" as const }
@@ -253,48 +261,18 @@ export function SessionTurn(
   })
 
   const permissions = createMemo(() => data.store.permission?.[props.sessionID] ?? emptyPermissions)
-  const permissionCount = createMemo(() => permissions().length)
   const nextPermission = createMemo(() => permissions()[0])
 
-  const permissionParts = createMemo(() => {
-    if (props.stepsExpanded) return emptyPermissionParts
-
-    const next = nextPermission()
-    if (!next || !next.tool) return emptyPermissionParts
-
-    const message = findLast(assistantMessages(), (m) => m.id === next.tool!.messageID)
-    if (!message) return emptyPermissionParts
-
-    const parts = data.store.part[message.id] ?? emptyParts
-    for (const part of parts) {
-      if (part?.type !== "tool") continue
-      const tool = part as ToolPart
-      if (tool.callID === next.tool?.callID) return [{ part: tool, message }]
-    }
-
-    return emptyPermissionParts
-  })
-
   const questions = createMemo(() => data.store.question?.[props.sessionID] ?? emptyQuestions)
   const nextQuestion = createMemo(() => questions()[0])
 
-  const questionParts = createMemo(() => {
-    if (props.stepsExpanded) return emptyQuestionParts
-
-    const next = nextQuestion()
-    if (!next || !next.tool) return emptyQuestionParts
-
-    const message = findLast(assistantMessages(), (m) => m.id === next.tool!.messageID)
-    if (!message) return emptyQuestionParts
-
-    const parts = data.store.part[message.id] ?? emptyParts
-    for (const part of parts) {
-      if (part?.type !== "tool") continue
-      const tool = part as ToolPart
-      if (tool.callID === next.tool?.callID) return [{ part: tool, message }]
-    }
-
-    return emptyQuestionParts
+  const hidden = createMemo(() => {
+    const out: { messageID: string; callID: string }[] = []
+    const perm = nextPermission()
+    if (perm?.tool) out.push(perm.tool)
+    const question = nextQuestion()
+    if (question?.tool) out.push(question.tool)
+    return out
   })
 
   const answeredQuestionParts = createMemo(() => {
@@ -499,14 +477,6 @@ export function SessionTurn(
     onCleanup(() => clearInterval(timer))
   })
 
-  createEffect(
-    on(permissionCount, (count, prev) => {
-      if (!count) return
-      if (prev !== undefined && count <= prev) return
-      autoScroll.forceScrollToBottom()
-    }),
-  )
-
   let lastStatusChange = Date.now()
   let statusTimeout: number | undefined
   createEffect(() => {
@@ -664,6 +634,7 @@ export function SessionTurn(
                               responsePartId={responsePartId()}
                               hideResponsePart={hideResponsePart()}
                               hideReasoning={!working()}
+                              hidden={hidden}
                             />
                           )}
                         </For>
@@ -674,20 +645,6 @@ export function SessionTurn(
                         </Show>
                       </div>
                     </Show>
-                    <Show when={!props.stepsExpanded && permissionParts().length > 0}>
-                      <div data-slot="session-turn-permission-parts">
-                        <For each={permissionParts()}>
-                          {({ part, message }) => <Part part={part} message={message} />}
-                        </For>
-                      </div>
-                    </Show>
-                    <Show when={!props.stepsExpanded && questionParts().length > 0}>
-                      <div data-slot="session-turn-question-parts">
-                        <For each={questionParts()}>
-                          {({ part, message }) => <Part part={part} message={message} />}
-                        </For>
-                      </div>
-                    </Show>
                     <Show when={!props.stepsExpanded && answeredQuestionParts().length > 0}>
                       <div data-slot="session-turn-answered-question-parts">
                         <For each={answeredQuestionParts()}>