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

fix(app): question UI fixes and UX improvements (#34116)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Julian Coy 2 месяцев назад
Родитель
Сommit
cd00422298

+ 23 - 0
packages/app/e2e/regression/session-request-docks.spec.ts

@@ -38,6 +38,29 @@ test("shows a pending question dock", async ({ page }) => {
   await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
   await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
 
+  const rejectRequests: string[] = []
+  page.on("request", (request) => {
+    if (request.method() !== "POST") return
+    if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
+  })
+
+  await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
+  await expect(question).toBeVisible()
+  await expect(question.getByText("Which implementation should be used?")).toBeVisible()
+  await expect(question.getByText("Select one answer")).toBeHidden()
+  await expect(question.getByRole("radio", { name: /Minimal/ })).toBeHidden()
+  await expect(question.getByRole("radio", { name: /Extended/ })).toBeHidden()
+  await expect(question.getByRole("button", { name: "Dismiss" })).toBeVisible()
+  await expect(question.getByRole("button", { name: "Submit" })).toBeVisible()
+  await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0)
+  expect(rejectRequests).toEqual([])
+
+  await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
+  await expect(question).toBeVisible()
+  await expect(question.getByText("Which implementation should be used?")).toBeVisible()
+  await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
+  expect(rejectRequests).toEqual([])
+
   await question.getByRole("radio", { name: /Minimal/ }).click()
   const reply = page.waitForRequest(
     (request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",

+ 4 - 0
packages/app/src/i18n/en.ts

@@ -657,6 +657,10 @@ export const dict = {
   "session.todo.expand": "Expand",
   "session.todo.progress": "{{done}} of {{total}} todos completed",
   "session.question.progress": "{{current}} of {{total}} questions",
+  "session.question.minimize": "Minimize question",
+  "session.question.restore": "Restore question",
+  "session.question.pending.one": "{{count}} pending question",
+  "session.question.pending.other": "{{count}} pending questions",
   "session.followupDock.summary.one": "{{count}} queued message",
   "session.followupDock.summary.other": "{{count}} queued messages",
   "session.followupDock.sendNow": "Send now",

+ 205 - 142
packages/app/src/pages/session/composer/session-question-dock.tsx

@@ -1,9 +1,10 @@
-import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
+import { For, Show, createEffect, createMemo, onCleanup, onMount, type Component } from "solid-js"
 import { createStore } from "solid-js/store"
 import { useMutation } from "@tanstack/solid-query"
 import { Button } from "@opencode-ai/ui/button"
 import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
 import { Icon } from "@opencode-ai/ui/icon"
+import { useSpring } from "@opencode-ai/ui/motion-spring"
 import { showToast } from "@/utils/toast"
 import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
 import { useLanguage } from "@/context/language"
@@ -77,9 +78,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     customOn: cached?.customOn ?? ([] as boolean[]),
     editing: false,
     focus: 0,
+    minimized: false,
+    optionsHeight: 180,
   })
 
   let root: HTMLDivElement | undefined
+  let optionsRef: HTMLDivElement | undefined
   let customRef: HTMLButtonElement | undefined
   let optsRef: HTMLButtonElement[] = []
   let replied = false
@@ -96,11 +100,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     const n = Math.min(store.tab + 1, total())
     return language.t("session.question.progress", { current: n, total: total() })
   })
-
   const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
   const customPlaceholder = () => language.t("ui.question.custom.placeholder")
 
   const last = createMemo(() => store.tab >= total() - 1)
+  const collapse = useSpring(() => (store.minimized ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
+  const hidden = createMemo(() => Math.max(0, Math.min(1, collapse())))
+  const optionsOff = createMemo(() => hidden() > 0.98)
 
   const customUpdate = (value: string, selected: boolean = on()) => {
     const prev = input().trim()
@@ -192,6 +198,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     focus(pickFocus())
   })
 
+  createEffect(() => {
+    const el = optionsRef
+    if (!el) return
+    const update = () => setStore("optionsHeight", (height) => Math.max(height, el.scrollHeight))
+    update()
+    createResizeObserver(el, update)
+  })
+
   onCleanup(() => {
     if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
     if (replied) return
@@ -405,7 +419,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     const tab = store.tab + 1
     setStore("tab", tab)
     setStore("editing", false)
-    focus(pickFocus(tab))
+    if (!store.minimized) focus(pickFocus(tab))
   }
 
   const back = () => {
@@ -414,163 +428,212 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     const tab = store.tab - 1
     setStore("tab", tab)
     setStore("editing", false)
-    focus(pickFocus(tab))
+    if (!store.minimized) focus(pickFocus(tab))
   }
 
   const jump = (tab: number) => {
     if (sending()) return
     setStore("tab", tab)
     setStore("editing", false)
-    focus(pickFocus(tab))
+    if (!store.minimized) focus(pickFocus(tab))
+  }
+
+  const minimize = () => {
+    if (sending()) return
+    setStore("editing", false)
+    setStore("minimized", true)
+  }
+
+  const restore = () => {
+    if (sending()) return
+    setStore("minimized", false)
+    focus(pickFocus())
   }
 
   return (
-    <DockPrompt
-      kind="question"
-      ref={(el) => (root = el)}
-      onKeyDown={nav}
-      header={
-        <>
-          <div data-slot="question-header-title">{summary()}</div>
-          <Show when={total() > 1}>
-            <div data-slot="question-progress">
-              <For each={questions()}>
-                {(_, i) => (
-                  <button
-                    type="button"
-                    data-slot="question-progress-segment"
-                    data-active={i() === store.tab}
-                    data-answered={answered(i())}
-                    disabled={sending()}
-                    onClick={() => jump(i())}
-                    aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
-                  />
-                )}
-              </For>
+    <div data-component="session-question-dock">
+      <DockPrompt
+        kind="question"
+        ref={(el) => (root = el)}
+        onKeyDown={nav}
+        header={
+          <>
+            <div data-slot="question-header-title">{summary()}</div>
+            <div data-slot="question-header-actions">
+              <Show when={total() > 1}>
+                <div data-slot="question-progress">
+                  <For each={questions()}>
+                    {(_, i) => (
+                      <button
+                        type="button"
+                        data-slot="question-progress-segment"
+                        data-active={i() === store.tab}
+                        data-answered={answered(i())}
+                        disabled={sending()}
+                        onClick={() => jump(i())}
+                        aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
+                      />
+                    )}
+                  </For>
+                </div>
+              </Show>
+              <button
+                type="button"
+                data-component="icon-button"
+                data-icon="chevron-down"
+                data-size="normal"
+                data-variant="ghost"
+                disabled={sending()}
+                style={{ transform: `rotate(${hidden() * 180}deg)` }}
+                onClick={store.minimized ? restore : minimize}
+                aria-label={language.t(store.minimized ? "session.question.restore" : "session.question.minimize")}
+              >
+                <Icon name="chevron-down" size="small" />
+              </button>
             </div>
-          </Show>
-        </>
-      }
-      footer={
-        <>
-          <Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
-            {language.t("ui.common.dismiss")}
-          </Button>
-          <div data-slot="question-footer-actions">
-            <Show when={store.tab > 0}>
-              <Button variant="secondary" size="large" disabled={sending()} onClick={back}>
-                {language.t("ui.common.back")}
-              </Button>
-            </Show>
-            <Button
-              variant={last() ? "primary" : "secondary"}
-              size="large"
-              disabled={sending()}
-              onClick={next}
-              aria-keyshortcuts="Meta+Enter Control+Enter"
-            >
-              {last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
+          </>
+        }
+        footer={
+          <>
+            <Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
+              {language.t("ui.common.dismiss")}
             </Button>
-          </div>
-        </>
-      }
-    >
-      <div data-slot="question-text" class="overflow-auto">
-        {question()?.question}
-      </div>
-      <Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
-        <div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
-      </Show>
-      <div data-slot="question-options">
-        <For each={options()}>
-          {(opt, i) => (
-            <Option
-              multi={multi()}
-              picked={picked(opt.label)}
-              label={opt.label}
-              description={opt.description}
-              disabled={sending()}
-              ref={(el) => (optsRef[i()] = el)}
-              onFocus={() => setStore("focus", i())}
-              onClick={() => selectOption(i())}
-            />
-          )}
-        </For>
-
-        <Show
-          when={store.editing}
-          fallback={
-            <button
-              type="button"
-              ref={customRef}
-              data-slot="question-option"
-              data-custom="true"
-              data-picked={on()}
-              role={multi() ? "checkbox" : "radio"}
-              aria-checked={on()}
-              disabled={sending()}
-              onFocus={() => setStore("focus", options().length)}
-              onClick={customOpen}
-            >
-              <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
-              <span data-slot="question-option-main">
-                <span data-slot="option-label">{customLabel()}</span>
-                <span data-slot="option-description">{input() || customPlaceholder()}</span>
-              </span>
-            </button>
-          }
+            <div data-slot="question-footer-actions">
+              <Show when={store.tab > 0}>
+                <Button variant="secondary" size="large" disabled={sending()} onClick={back}>
+                  {language.t("ui.common.back")}
+                </Button>
+              </Show>
+              <Button
+                variant={last() ? "primary" : "secondary"}
+                size="large"
+                disabled={sending()}
+                onClick={next}
+                aria-keyshortcuts="Meta+Enter Control+Enter"
+              >
+                {last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
+              </Button>
+            </div>
+          </>
+        }
+      >
+        <div
+          data-slot="question-text"
+          style={{
+            display: store.minimized ? "-webkit-box" : undefined,
+            "-webkit-line-clamp": store.minimized ? "3" : undefined,
+            "-webkit-box-orient": store.minimized ? "vertical" : undefined,
+            overflow: store.minimized ? "hidden" : undefined,
+          }}
         >
-          <form
-            data-slot="question-option"
-            data-custom="true"
-            data-picked={on()}
-            role={multi() ? "checkbox" : "radio"}
-            aria-checked={on()}
-            onMouseDown={(e) => {
-              if (sending()) {
-                e.preventDefault()
-                return
-              }
-              if (e.target instanceof HTMLTextAreaElement) return
-              const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
-              if (input instanceof HTMLTextAreaElement) input.focus()
-            }}
-            onSubmit={(e) => {
-              e.preventDefault()
-              commitCustom()
-            }}
-          >
-            <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
-            <span data-slot="question-option-main">
-              <span data-slot="option-label">{customLabel()}</span>
-              <textarea
-                ref={focusCustom}
-                data-slot="question-custom-input"
-                placeholder={customPlaceholder()}
-                value={input()}
-                rows={1}
+          {question()?.question}
+        </div>
+        <Show when={!store.minimized}>
+          <Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
+            <div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
+          </Show>
+        </Show>
+        <div
+          ref={(el) => (optionsRef = el)}
+          data-slot="question-options"
+          aria-hidden={store.minimized || optionsOff() ? "true" : undefined}
+          classList={{ "pointer-events-none": hidden() > 0.1 }}
+          style={{
+            "max-height": `${Math.max(0, store.optionsHeight * (1 - hidden()))}px`,
+            opacity: `${Math.max(0, Math.min(1, 1 - hidden()))}`,
+            visibility: optionsOff() ? "hidden" : "visible",
+          }}
+        >
+          <For each={options()}>
+            {(opt, i) => (
+              <Option
+                multi={multi()}
+                picked={picked(opt.label)}
+                label={opt.label}
+                description={opt.description}
+                disabled={sending()}
+                ref={(el) => (optsRef[i()] = el)}
+                onFocus={() => setStore("focus", i())}
+                onClick={() => selectOption(i())}
+              />
+            )}
+          </For>
+
+          <Show
+            when={store.editing}
+            fallback={
+              <button
+                type="button"
+                ref={customRef}
+                data-slot="question-option"
+                data-custom="true"
+                data-picked={on()}
+                role={multi() ? "checkbox" : "radio"}
+                aria-checked={on()}
                 disabled={sending()}
-                onKeyDown={(e) => {
-                  if (e.key === "Escape") {
+                onFocus={() => setStore("focus", options().length)}
+                onClick={customOpen}
+              >
+                <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
+                <span data-slot="question-option-main">
+                  <span data-slot="option-label">{customLabel()}</span>
+                  <span data-slot="option-description">{input() || customPlaceholder()}</span>
+                </span>
+              </button>
+            }
+          >
+            <form
+                data-slot="question-option"
+                data-custom="true"
+                data-picked={on()}
+                role={multi() ? "checkbox" : "radio"}
+                aria-checked={on()}
+                onMouseDown={(e) => {
+                  if (sending()) {
                     e.preventDefault()
-                    setStore("editing", false)
-                    focus(options().length)
                     return
                   }
-                  if ((e.metaKey || e.ctrlKey) && !e.altKey) return
-                  if (e.key !== "Enter" || e.shiftKey) return
+                  if (e.target instanceof HTMLTextAreaElement) return
+                  const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
+                  if (input instanceof HTMLTextAreaElement) input.focus()
+                }}
+                onSubmit={(e) => {
                   e.preventDefault()
                   commitCustom()
                 }}
-                onInput={(e) => {
-                  customUpdate(e.currentTarget.value)
-                  resizeInput(e.currentTarget)
-                }}
-              />
-            </span>
-          </form>
-        </Show>
-      </div>
-    </DockPrompt>
+              >
+                <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
+                <span data-slot="question-option-main">
+                  <span data-slot="option-label">{customLabel()}</span>
+                  <textarea
+                    ref={focusCustom}
+                    data-slot="question-custom-input"
+                    placeholder={customPlaceholder()}
+                    value={input()}
+                    rows={1}
+                    disabled={sending()}
+                    onKeyDown={(e) => {
+                      if (e.key === "Escape") {
+                        e.preventDefault()
+                        setStore("editing", false)
+                        focus(options().length)
+                        return
+                      }
+                      if ((e.metaKey || e.ctrlKey) && !e.altKey) return
+                      if (e.key !== "Enter" || e.shiftKey) return
+                      e.preventDefault()
+                      commitCustom()
+                    }}
+                    onInput={(e) => {
+                      customUpdate(e.currentTarget.value)
+                      resizeInput(e.currentTarget)
+                    }}
+                  />
+                </span>
+              </form>
+          </Show>
+        </div>
+      </DockPrompt>
+    </div>
   )
 }

+ 47 - 2
packages/session-ui/src/components/message-part.css

@@ -860,6 +860,13 @@
     min-width: 0;
   }
 
+  [data-slot="question-header-actions"] {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex-shrink: 0;
+  }
+
   [data-slot="question-progress"] {
     display: flex;
     align-items: center;
@@ -918,6 +925,7 @@
     line-height: var(--line-height-large);
     color: var(--v2-text-text-base);
     padding: 0 10px;
+    flex-shrink: 0;
     -webkit-user-select: text;
     user-select: text;
   }
@@ -929,6 +937,7 @@
     line-height: var(--line-height-large);
     color: var(--v2-text-text-muted);
     padding: 0 10px;
+    flex-shrink: 0;
   }
 
   [data-slot="question-options"] {
@@ -936,8 +945,8 @@
     flex-direction: column;
     gap: 6px;
     margin-top: 12px;
-    padding: 1px 1px 8px;
-    flex-shrink: 0;
+    padding: 1px 1px 16px;
+    flex: 1 1 auto;
     min-height: 0;
     overflow-y: auto;
     scrollbar-width: none;
@@ -1126,6 +1135,42 @@
   }
 }
 
+[data-component="question-minimized-dock"] {
+  margin-bottom: 8px;
+
+  [data-slot="question-minimized-trigger"] {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    width: 100%;
+    padding: 10px 12px;
+    border: 0;
+    background: transparent;
+    color: var(--v2-text-text-base);
+    text-align: left;
+    cursor: pointer;
+    touch-action: manipulation;
+
+    &:disabled {
+      cursor: not-allowed;
+      opacity: 0.6;
+    }
+
+    [data-component="icon"] {
+      transform: rotate(180deg);
+    }
+  }
+
+  [data-slot="question-minimized-summary"] {
+    flex: 1;
+    font-family: var(--font-family-sans);
+    font-size: 14px;
+    font-weight: var(--font-weight-medium);
+    line-height: var(--line-height-large);
+    color: var(--v2-text-text-base);
+  }
+}
+
 [data-component="question-answers"] {
   display: flex;
   flex-direction: column;