refactor(ui): extract dock prompt shell

This commit is contained in:
David Hill
2026-02-17 17:06:21 +00:00
parent b784c923a8
commit 2c17a980ff
4 changed files with 336 additions and 230 deletions

View File

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

View File

@@ -1,6 +1,7 @@
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2" import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
import { QuestionDock } from "@/components/question-dock" import { QuestionDock } from "@/components/question-dock"
import { SessionTodoDock } from "@/components/session-todo-dock" import { SessionTodoDock } from "@/components/session-todo-dock"
@@ -139,64 +140,57 @@ export function SessionPromptDock(props: {
return ( return (
<div> <div>
<div data-component="question-prompt" data-permission="true"> <DockPrompt
<div data-slot="question-body"> kind="permission"
<div data-slot="question-header"> header={
<div data-slot="question-header-title"> <>
{props.t("notification.permission.title")}{" "} <div data-slot="permission-header-title">{props.t("notification.permission.title")}</div>
<span class="text-13-regular text-text-weak">{toolTitle()}</span> </>
}
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> </div>
</div> </>
}
>
<Show when={toolDescription()}>
<div data-slot="permission-hint">{toolDescription()}</div>
</Show>
<div data-slot="question-content"> <Show when={perm.patterns.length > 0}>
<Show when={toolDescription()}> <div data-slot="permission-patterns">
<div data-slot="question-hint">{toolDescription()}</div> <For each={perm.patterns}>
</Show> {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
</For>
<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> </Show>
</DockPrompt>
<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>
</div> </div>
) )
}} }}

View File

@@ -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>
)
}

View File

@@ -753,7 +753,114 @@
} }
} }
[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: 100dvh;
[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;
}
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; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -761,22 +868,6 @@
min-height: 0; min-height: 0;
max-height: var(--question-prompt-max-height, 100dvh); max-height: var(--question-prompt-max-height, 100dvh);
&[data-permission="true"] {
[data-slot="question-options"] {
code {
font-size: 14px;
line-height: var(--line-height-large);
}
}
[data-slot="question-footer-actions"] {
[data-component="button"] {
padding-left: 12px;
padding-right: 12px;
}
}
}
[data-slot="question-body"] { [data-slot="question-body"] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;