summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Hill <[email protected]>2026-02-17 17:06:21 +0000
committerDavid Hill <[email protected]>2026-02-17 17:43:37 +0000
commit2c17a980ffdc019d46b9e48a22bf719c009075e0 (patch)
tree9bb151f2cd0be28daa582f4779ec7014776cab9c
parentb784c923a8eeab52412eaebb9a44ad05a1411165 (diff)
downloadopencode-2c17a980ffdc019d46b9e48a22bf719c009075e0.tar.gz
opencode-2c17a980ffdc019d46b9e48a22bf719c009075e0.zip
refactor(ui): extract dock prompt shell
-rw-r--r--packages/app/src/components/question-dock.tsx316
-rw-r--r--packages/app/src/pages/session/session-prompt-dock.tsx106
-rw-r--r--packages/ui/src/components/dock-prompt.tsx21
-rw-r--r--packages/ui/src/components/message-part.css117
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;