Explorar o código

refactor(ui): extract dock prompt shell

David Hill hai 6 meses
pai
achega
2c17a980ff

+ 158 - 158
packages/app/src/components/question-dock.tsx

@@ -1,6 +1,7 @@
 import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Button } from "@opencode-ai/ui/button"
+import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
 import { Icon } from "@opencode-ai/ui/icon"
 import { showToast } from "@opencode-ai/ui/toast"
 import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
@@ -232,9 +233,11 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
   }
 
   return (
-    <div data-component="question-prompt" ref={(el) => (root = el)}>
-      <div data-slot="question-body">
-        <div data-slot="question-header">
+    <DockPrompt
+      kind="question"
+      ref={(el) => (root = el)}
+      header={
+        <>
           <div data-slot="question-header-title">{summary()}</div>
           <div data-slot="question-progress">
             <For each={questions()}>
@@ -254,172 +257,169 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
               )}
             </For>
           </div>
-        </div>
-
-        <div data-slot="question-content">
-          <div data-slot="question-text">{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) => {
-                const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
-                return (
-                  <button
-                    data-slot="question-option"
-                    data-picked={picked()}
-                    role={multi() ? "checkbox" : "radio"}
-                    aria-checked={picked()}
-                    disabled={store.sending}
-                    onClick={() => selectOption(i())}
-                  >
-                    <span data-slot="question-option-check" aria-hidden="true">
-                      <span
-                        data-slot="question-option-box"
-                        data-type={multi() ? "checkbox" : "radio"}
-                        data-picked={picked()}
-                      >
-                        <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
-                          <Icon name="check-small" size="small" />
-                        </Show>
-                      </span>
-                    </span>
-                    <span data-slot="question-option-main">
-                      <span data-slot="option-label">{opt.label}</span>
-                      <Show when={opt.description}>
-                        <span data-slot="option-description">{opt.description}</span>
-                      </Show>
-                    </span>
-                  </button>
-                )
-              }}
-            </For>
-
-            <Show
-              when={store.editing}
-              fallback={
-                <button
-                  data-slot="question-option"
-                  data-custom="true"
-                  data-picked={on()}
-                  role={multi() ? "checkbox" : "radio"}
-                  aria-checked={on()}
-                  disabled={store.sending}
-                  onClick={customOpen}
-                >
+        </>
+      }
+      footer={
+        <>
+          <Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
+            {language.t("ui.common.dismiss")}
+          </Button>
+          <div data-slot="question-footer-actions">
+            <Show when={store.tab > 0}>
+              <Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
+                {language.t("ui.common.back")}
+              </Button>
+            </Show>
+            <Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
+              {last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
+            </Button>
+          </div>
+        </>
+      }
+    >
+      <div data-slot="question-text">{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) => {
+            const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
+            return (
+              <button
+                data-slot="question-option"
+                data-picked={picked()}
+                role={multi() ? "checkbox" : "radio"}
+                aria-checked={picked()}
+                disabled={store.sending}
+                onClick={() => selectOption(i())}
+              >
+                <span data-slot="question-option-check" aria-hidden="true">
                   <span
-                    data-slot="question-option-check"
-                    aria-hidden="true"
-                    onClick={(e) => {
-                      e.preventDefault()
-                      e.stopPropagation()
-                      customToggle()
-                    }}
+                    data-slot="question-option-box"
+                    data-type={multi() ? "checkbox" : "radio"}
+                    data-picked={picked()}
                   >
-                    <span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
-                      <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
-                        <Icon name="check-small" size="small" />
-                      </Show>
-                    </span>
-                  </span>
-                  <span data-slot="question-option-main">
-                    <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
-                    <span data-slot="option-description">
-                      {input() || language.t("ui.question.custom.placeholder")}
-                    </span>
+                    <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
+                      <Icon name="check-small" size="small" />
+                    </Show>
                   </span>
-                </button>
+                </span>
+                <span data-slot="question-option-main">
+                  <span data-slot="option-label">{opt.label}</span>
+                  <Show when={opt.description}>
+                    <span data-slot="option-description">{opt.description}</span>
+                  </Show>
+                </span>
+              </button>
+            )
+          }}
+        </For>
+
+        <Show
+          when={store.editing}
+          fallback={
+            <button
+              data-slot="question-option"
+              data-custom="true"
+              data-picked={on()}
+              role={multi() ? "checkbox" : "radio"}
+              aria-checked={on()}
+              disabled={store.sending}
+              onClick={customOpen}
+            >
+              <span
+                data-slot="question-option-check"
+                aria-hidden="true"
+                onClick={(e) => {
+                  e.preventDefault()
+                  e.stopPropagation()
+                  customToggle()
+                }}
+              >
+                <span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
+                  <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
+                    <Icon name="check-small" size="small" />
+                  </Show>
+                </span>
+              </span>
+              <span data-slot="question-option-main">
+                <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
+                <span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
+              </span>
+            </button>
+          }
+        >
+          <form
+            data-slot="question-option"
+            data-custom="true"
+            data-picked={on()}
+            role={multi() ? "checkbox" : "radio"}
+            aria-checked={on()}
+            onMouseDown={(e) => {
+              if (store.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()
+            }}
+          >
+            <span
+              data-slot="question-option-check"
+              aria-hidden="true"
+              onClick={(e) => {
+                e.preventDefault()
+                e.stopPropagation()
+                customToggle()
+              }}
             >
-              <form
-                data-slot="question-option"
-                data-custom="true"
-                data-picked={on()}
-                role={multi() ? "checkbox" : "radio"}
-                aria-checked={on()}
-                onMouseDown={(e) => {
-                  if (store.sending) {
+              <span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
+                <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
+                  <Icon name="check-small" size="small" />
+                </Show>
+              </span>
+            </span>
+            <span data-slot="question-option-main">
+              <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
+              <textarea
+                ref={(el) =>
+                  setTimeout(() => {
+                    el.focus()
+                    el.style.height = "0px"
+                    el.style.height = `${el.scrollHeight}px`
+                  }, 0)
+                }
+                data-slot="question-custom-input"
+                placeholder={language.t("ui.question.custom.placeholder")}
+                value={input()}
+                rows={1}
+                disabled={store.sending}
+                onKeyDown={(e) => {
+                  if (e.key === "Escape") {
                     e.preventDefault()
+                    setStore("editing", false)
                     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) => {
+                  if (e.key !== "Enter" || e.shiftKey) return
                   e.preventDefault()
                   commitCustom()
                 }}
-              >
-                <span
-                  data-slot="question-option-check"
-                  aria-hidden="true"
-                  onClick={(e) => {
-                    e.preventDefault()
-                    e.stopPropagation()
-                    customToggle()
-                  }}
-                >
-                  <span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
-                    <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
-                      <Icon name="check-small" size="small" />
-                    </Show>
-                  </span>
-                </span>
-                <span data-slot="question-option-main">
-                  <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
-                  <textarea
-                    ref={(el) =>
-                      setTimeout(() => {
-                        el.focus()
-                        el.style.height = "0px"
-                        el.style.height = `${el.scrollHeight}px`
-                      }, 0)
-                    }
-                    data-slot="question-custom-input"
-                    placeholder={language.t("ui.question.custom.placeholder")}
-                    value={input()}
-                    rows={1}
-                    disabled={store.sending}
-                    onKeyDown={(e) => {
-                      if (e.key === "Escape") {
-                        e.preventDefault()
-                        setStore("editing", false)
-                        return
-                      }
-                      if (e.key !== "Enter" || e.shiftKey) return
-                      e.preventDefault()
-                      commitCustom()
-                    }}
-                    onInput={(e) => {
-                      customUpdate(e.currentTarget.value)
-                      e.currentTarget.style.height = "0px"
-                      e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
-                    }}
-                  />
-                </span>
-              </form>
-            </Show>
-          </div>
-        </div>
-      </div>
-
-      <div data-slot="question-footer">
-        <Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
-          {language.t("ui.common.dismiss")}
-        </Button>
-        <div data-slot="question-footer-actions">
-          <Show when={store.tab > 0}>
-            <Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
-              {language.t("ui.common.back")}
-            </Button>
-          </Show>
-          <Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
-            {last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
-          </Button>
-        </div>
+                onInput={(e) => {
+                  customUpdate(e.currentTarget.value)
+                  e.currentTarget.style.height = "0px"
+                  e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
+                }}
+              />
+            </span>
+          </form>
+        </Show>
       </div>
-    </div>
+    </DockPrompt>
   )
 }

+ 50 - 56
packages/app/src/pages/session/session-prompt-dock.tsx

@@ -1,6 +1,7 @@
 import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
 import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
 import { Button } from "@opencode-ai/ui/button"
+import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
 import { PromptInput } from "@/components/prompt-input"
 import { QuestionDock } from "@/components/question-dock"
 import { SessionTodoDock } from "@/components/session-todo-dock"
@@ -139,64 +140,57 @@ export function SessionPromptDock(props: {
 
             return (
               <div>
-                <div data-component="question-prompt" data-permission="true">
-                  <div data-slot="question-body">
-                    <div data-slot="question-header">
-                      <div data-slot="question-header-title">
-                        {props.t("notification.permission.title")}{" "}
-                        <span class="text-13-regular text-text-weak">{toolTitle()}</span>
+                <DockPrompt
+                  kind="permission"
+                  header={
+                    <>
+                      <div data-slot="permission-header-title">{props.t("notification.permission.title")}</div>
+                    </>
+                  }
+                  footer={
+                    <>
+                      <div />
+                      <div data-slot="permission-footer-actions">
+                        <Button
+                          variant="ghost"
+                          size="normal"
+                          onClick={() => props.onDecide("reject")}
+                          disabled={props.responding}
+                        >
+                          {props.t("ui.permission.deny")}
+                        </Button>
+                        <Button
+                          variant="secondary"
+                          size="normal"
+                          onClick={() => props.onDecide("always")}
+                          disabled={props.responding}
+                        >
+                          {props.t("ui.permission.allowAlways")}
+                        </Button>
+                        <Button
+                          variant="primary"
+                          size="normal"
+                          onClick={() => props.onDecide("once")}
+                          disabled={props.responding}
+                        >
+                          {props.t("ui.permission.allowOnce")}
+                        </Button>
                       </div>
+                    </>
+                  }
+                >
+                  <Show when={toolDescription()}>
+                    <div data-slot="permission-hint">{toolDescription()}</div>
+                  </Show>
+
+                  <Show when={perm.patterns.length > 0}>
+                    <div data-slot="permission-patterns">
+                      <For each={perm.patterns}>
+                        {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
+                      </For>
                     </div>
-
-                    <div data-slot="question-content">
-                      <Show when={toolDescription()}>
-                        <div data-slot="question-hint">{toolDescription()}</div>
-                      </Show>
-
-                      <Show when={perm.patterns.length > 0}>
-                        <div data-slot="question-options">
-                          <For each={perm.patterns}>
-                            {(pattern) => (
-                              <div class="px-[10px]">
-                                <code class="text-12-regular text-text-base break-all">{pattern}</code>
-                              </div>
-                            )}
-                          </For>
-                        </div>
-                      </Show>
-                    </div>
-                  </div>
-
-                  <div data-slot="question-footer">
-                    <div />
-                    <div data-slot="question-footer-actions">
-                      <Button
-                        variant="ghost"
-                        size="normal"
-                        onClick={() => props.onDecide("reject")}
-                        disabled={props.responding}
-                      >
-                        {props.t("ui.permission.deny")}
-                      </Button>
-                      <Button
-                        variant="secondary"
-                        size="normal"
-                        onClick={() => props.onDecide("always")}
-                        disabled={props.responding}
-                      >
-                        {props.t("ui.permission.allowAlways")}
-                      </Button>
-                      <Button
-                        variant="primary"
-                        size="normal"
-                        onClick={() => props.onDecide("once")}
-                        disabled={props.responding}
-                      >
-                        {props.t("ui.permission.allowOnce")}
-                      </Button>
-                    </div>
-                  </div>
-                </div>
+                  </Show>
+                </DockPrompt>
               </div>
             )
           }}

+ 21 - 0
packages/ui/src/components/dock-prompt.tsx

@@ -0,0 +1,21 @@
+import type { JSX } from "solid-js"
+
+export function DockPrompt(props: {
+  kind: "question" | "permission"
+  header: JSX.Element
+  children: JSX.Element
+  footer: JSX.Element
+  ref?: (el: HTMLDivElement) => void
+}) {
+  const slot = (name: string) => `${props.kind}-${name}`
+
+  return (
+    <div data-component="dock-prompt" data-kind={props.kind} ref={props.ref}>
+      <div data-slot={slot("body")}>
+        <div data-slot={slot("header")}>{props.header}</div>
+        <div data-slot={slot("content")}>{props.children}</div>
+      </div>
+      <div data-slot={slot("footer")}>{props.footer}</div>
+    </div>
+  )
+}

+ 104 - 13
packages/ui/src/components/message-part.css

@@ -753,30 +753,121 @@
   }
 }
 
-[data-component="question-prompt"] {
+[data-component="dock-prompt"][data-kind="permission"] {
   position: relative;
   display: flex;
   flex-direction: column;
   gap: 0;
   min-height: 0;
-  max-height: var(--question-prompt-max-height, 100dvh);
+  max-height: 100dvh;
 
-  &[data-permission="true"] {
-    [data-slot="question-options"] {
-      code {
-        font-size: 14px;
-        line-height: var(--line-height-large);
-      }
+  [data-slot="permission-body"] {
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+    flex: 1;
+    min-height: 0;
+    padding: 8px 8px 0;
+    background-color: var(--surface-raised-stronger-non-alpha);
+    border-radius: 12px;
+    box-shadow: var(--shadow-xs-border);
+    overflow: clip;
+    position: relative;
+    z-index: 10;
+  }
+
+  [data-slot="permission-header"] {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+    padding: 0 10px;
+  }
+
+  [data-slot="permission-header-title"] {
+    font-family: var(--font-family-sans);
+    font-size: 14px;
+    font-weight: var(--font-weight-medium);
+    line-height: var(--line-height-large);
+    color: var(--text-strong);
+    min-width: 0;
+  }
+
+  [data-slot="permission-content"] {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    flex: 1;
+    min-height: 0;
+  }
+
+  [data-slot="permission-hint"] {
+    font-family: var(--font-family-sans);
+    font-size: 14px;
+    font-weight: var(--font-weight-regular);
+    line-height: var(--line-height-large);
+    color: var(--text-weak);
+    padding: 0 10px;
+  }
+
+  [data-slot="permission-patterns"] {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    margin-top: 8px;
+    margin-bottom: 16px;
+    padding: 1px 10px 8px;
+    flex: 1;
+    min-height: 0;
+    overflow-y: auto;
+    scrollbar-width: none;
+    -ms-overflow-style: none;
+
+    &::-webkit-scrollbar {
+      display: none;
     }
 
-    [data-slot="question-footer-actions"] {
-      [data-component="button"] {
-        padding-left: 12px;
-        padding-right: 12px;
-      }
+    code {
+      font-size: 14px;
+      line-height: var(--line-height-large);
     }
   }
 
+  [data-slot="permission-footer"] {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    flex-shrink: 0;
+    padding: 32px 8px 8px;
+    background-color: var(--background-base);
+    border: 1px solid var(--border-weak-base);
+    border-radius: 12px;
+    overflow: clip;
+    margin-top: -24px;
+    position: relative;
+    z-index: 0;
+  }
+
+  [data-slot="permission-footer-actions"] {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+
+    [data-component="button"] {
+      padding-left: 12px;
+      padding-right: 12px;
+    }
+  }
+}
+
+:is([data-component="question-prompt"], [data-component="dock-prompt"][data-kind="question"]) {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  gap: 0;
+  min-height: 0;
+  max-height: var(--question-prompt-max-height, 100dvh);
+
   [data-slot="question-body"] {
     display: flex;
     flex-direction: column;