diff options
| author | David Hill <[email protected]> | 2026-02-17 17:06:21 +0000 |
|---|---|---|
| committer | David Hill <[email protected]> | 2026-02-17 17:43:37 +0000 |
| commit | 2c17a980ffdc019d46b9e48a22bf719c009075e0 (patch) | |
| tree | 9bb151f2cd0be28daa582f4779ec7014776cab9c | |
| parent | b784c923a8eeab52412eaebb9a44ad05a1411165 (diff) | |
| download | opencode-2c17a980ffdc019d46b9e48a22bf719c009075e0.tar.gz opencode-2c17a980ffdc019d46b9e48a22bf719c009075e0.zip | |
refactor(ui): extract dock prompt shell
| -rw-r--r-- | packages/app/src/components/question-dock.tsx | 316 | ||||
| -rw-r--r-- | packages/app/src/pages/session/session-prompt-dock.tsx | 106 | ||||
| -rw-r--r-- | packages/ui/src/components/dock-prompt.tsx | 21 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.css | 117 |
4 files changed, 333 insertions, 227 deletions
diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx index 1a0bbbe97..cd2e495b1 100644 --- a/packages/app/src/components/question-dock.tsx +++ b/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> ) } diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx index 2bdb4ee2c..5728817fb 100644 --- a/packages/app/src/pages/session/session-prompt-dock.tsx +++ b/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> ) }} diff --git a/packages/ui/src/components/dock-prompt.tsx b/packages/ui/src/components/dock-prompt.tsx new file mode 100644 index 000000000..4def4862f --- /dev/null +++ b/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> + ) +} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index d8a35c0f1..2cfa286d2 100644 --- a/packages/ui/src/components/message-part.css +++ b/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; |
