summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/components/message-part.tsx
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-25 19:05:08 -0600
committerGitHub <[email protected]>2026-02-26 01:05:08 +0000
commitb8337cddc4269ba6e72f74c1e1f39aae41f56af3 (patch)
tree178a80a21af1a24f3568a0ef15e735a5d537c16f /packages/ui/src/components/message-part.tsx
parent444178e079fb41ba2149a1cdfdd3040593715d70 (diff)
downloadopencode-b8337cddc4269ba6e72f74c1e1f39aae41f56af3.tar.gz
opencode-b8337cddc4269ba6e72f74c1e1f39aae41f56af3.zip
fix(app): permissions and questions from child sessions (#15105)
Co-authored-by: adamelmore <[email protected]>
Diffstat (limited to 'packages/ui/src/components/message-part.tsx')
-rw-r--r--packages/ui/src/components/message-part.tsx325
1 files changed, 2 insertions, 323 deletions
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index adba42ce9..8fbad45bd 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -23,11 +23,9 @@ import {
ToolPart,
UserMessage,
Todo,
- QuestionRequest,
QuestionAnswer,
QuestionInfo,
} from "@opencode-ai/sdk/v2"
-import { createStore } from "solid-js/store"
import { useData } from "../context"
import { useDiffComponent } from "../context/diff"
import { useCodeComponent } from "../context/code"
@@ -37,7 +35,6 @@ import { BasicTool } from "./basic-tool"
import { GenericTool } from "./basic-tool"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
-import { Button } from "./button"
import { Card } from "./card"
import { Collapsible } from "./collapsible"
import { FileIcon } from "./file-icon"
@@ -950,7 +947,6 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
}
PART_MAPPING["tool"] = function ToolPartDisplay(props) {
- const data = useData()
const i18n = useI18n()
const part = props.part as ToolPart
if (part.tool === "todowrite" || part.tool === "todoread") return null
@@ -959,75 +955,18 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
() => part.tool === "question" && (part.state.status === "pending" || part.state.status === "running"),
)
- const permission = createMemo(() => {
- const next = data.store.permission?.[props.message.sessionID]?.[0]
- if (!next || !next.tool) return undefined
- if (next.tool!.callID !== part.callID) return undefined
- return next
- })
-
- const questionRequest = createMemo(() => {
- const next = data.store.question?.[props.message.sessionID]?.[0]
- if (!next || !next.tool) return undefined
- if (next.tool!.callID !== part.callID) return undefined
- return next
- })
-
- const [showPermission, setShowPermission] = createSignal(false)
- const [showQuestion, setShowQuestion] = createSignal(false)
-
- createEffect(() => {
- const perm = permission()
- if (perm) {
- const timeout = setTimeout(() => setShowPermission(true), 50)
- onCleanup(() => clearTimeout(timeout))
- } else {
- setShowPermission(false)
- }
- })
-
- createEffect(() => {
- const question = questionRequest()
- if (question) {
- const timeout = setTimeout(() => setShowQuestion(true), 50)
- onCleanup(() => clearTimeout(timeout))
- } else {
- setShowQuestion(false)
- }
- })
-
- const [forceOpen, setForceOpen] = createSignal(false)
- createEffect(() => {
- if (permission() || questionRequest()) setForceOpen(true)
- })
-
- const respond = (response: "once" | "always" | "reject") => {
- const perm = permission()
- if (!perm || !data.respondToPermission) return
- data.respondToPermission({
- sessionID: perm.sessionID,
- permissionID: perm.id,
- response,
- })
- }
-
const emptyInput: Record<string, any> = {}
const emptyMetadata: Record<string, any> = {}
const input = () => part.state?.input ?? emptyInput
// @ts-expect-error
const partMetadata = () => part.state?.metadata ?? emptyMetadata
- const metadata = () => {
- const perm = permission()
- if (perm?.metadata) return { ...perm.metadata, ...partMetadata() }
- return partMetadata()
- }
const render = ToolRegistry.render(part.tool) ?? GenericTool
return (
<Show when={!hideQuestion()}>
- <div data-component="tool-part-wrapper" data-permission={showPermission()} data-question={showQuestion()}>
+ <div data-component="tool-part-wrapper">
<Switch>
<Match when={part.state.status === "error" && part.state.error}>
{(error) => {
@@ -1067,33 +1006,15 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
component={render}
input={input()}
tool={part.tool}
- metadata={metadata()}
+ metadata={partMetadata()}
// @ts-expect-error
output={part.state.output}
status={part.state.status}
hideDetails={props.hideDetails}
- forceOpen={forceOpen()}
- locked={showPermission() || showQuestion()}
defaultOpen={props.defaultOpen}
/>
</Match>
</Switch>
- <Show when={showPermission() && permission()}>
- <div data-component="permission-prompt">
- <div data-slot="permission-actions">
- <Button variant="ghost" size="normal" onClick={() => respond("reject")}>
- {i18n.t("ui.permission.deny")}
- </Button>
- <Button variant="secondary" size="normal" onClick={() => respond("always")}>
- {i18n.t("ui.permission.allowAlways")}
- </Button>
- <Button variant="primary" size="normal" onClick={() => respond("once")}>
- {i18n.t("ui.permission.allowOnce")}
- </Button>
- </div>
- </div>
- </Show>
- <Show when={showQuestion() && questionRequest()}>{(request) => <QuestionPrompt request={request()} />}</Show>
</div>
</Show>
)
@@ -1963,245 +1884,3 @@ ToolRegistry.register({
)
},
})
-
-function QuestionPrompt(props: { request: QuestionRequest }) {
- const data = useData()
- const i18n = useI18n()
- const questions = createMemo(() => props.request.questions)
- const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
-
- const [store, setStore] = createStore({
- tab: 0,
- answers: [] as QuestionAnswer[],
- custom: [] as string[],
- editing: false,
- })
-
- const question = createMemo(() => questions()[store.tab])
- const confirm = createMemo(() => !single() && store.tab === questions().length)
- const options = createMemo(() => question()?.options ?? [])
- const input = createMemo(() => store.custom[store.tab] ?? "")
- const multi = createMemo(() => question()?.multiple === true)
- const customPicked = createMemo(() => {
- const value = input()
- if (!value) return false
- return store.answers[store.tab]?.includes(value) ?? false
- })
-
- function submit() {
- const answers = questions().map((_, i) => store.answers[i] ?? [])
- data.replyToQuestion?.({
- requestID: props.request.id,
- answers,
- })
- }
-
- function reject() {
- data.rejectQuestion?.({
- requestID: props.request.id,
- })
- }
-
- function pick(answer: string, custom: boolean = false) {
- const answers = [...store.answers]
- answers[store.tab] = [answer]
- setStore("answers", answers)
- if (custom) {
- const inputs = [...store.custom]
- inputs[store.tab] = answer
- setStore("custom", inputs)
- }
- if (single()) {
- data.replyToQuestion?.({
- requestID: props.request.id,
- answers: [[answer]],
- })
- return
- }
- setStore("tab", store.tab + 1)
- }
-
- function toggle(answer: string) {
- const existing = store.answers[store.tab] ?? []
- const next = [...existing]
- const index = next.indexOf(answer)
- if (index === -1) next.push(answer)
- if (index !== -1) next.splice(index, 1)
- const answers = [...store.answers]
- answers[store.tab] = next
- setStore("answers", answers)
- }
-
- function selectTab(index: number) {
- setStore("tab", index)
- setStore("editing", false)
- }
-
- function selectOption(optIndex: number) {
- if (optIndex === options().length) {
- setStore("editing", true)
- return
- }
- const opt = options()[optIndex]
- if (!opt) return
- if (multi()) {
- toggle(opt.label)
- return
- }
- pick(opt.label)
- }
-
- function handleCustomSubmit(e: Event) {
- e.preventDefault()
- const value = input().trim()
- if (!value) {
- setStore("editing", false)
- return
- }
- if (multi()) {
- const existing = store.answers[store.tab] ?? []
- const next = [...existing]
- if (!next.includes(value)) next.push(value)
- const answers = [...store.answers]
- answers[store.tab] = next
- setStore("answers", answers)
- setStore("editing", false)
- return
- }
- pick(value, true)
- setStore("editing", false)
- }
-
- return (
- <div data-component="question-prompt">
- <Show when={!single()}>
- <div data-slot="question-tabs">
- <For each={questions()}>
- {(q, index) => {
- const active = () => index() === store.tab
- const answered = () => (store.answers[index()]?.length ?? 0) > 0
- return (
- <button
- data-slot="question-tab"
- data-active={active()}
- data-answered={answered()}
- onClick={() => selectTab(index())}
- >
- {q.header}
- </button>
- )
- }}
- </For>
- <button data-slot="question-tab" data-active={confirm()} onClick={() => selectTab(questions().length)}>
- {i18n.t("ui.common.confirm")}
- </button>
- </div>
- </Show>
-
- <Show when={!confirm()}>
- <div data-slot="question-content">
- <div data-slot="question-text">
- {question()?.question}
- {multi() ? " " + i18n.t("ui.question.multiHint") : ""}
- </div>
- <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()} onClick={() => selectOption(i())}>
- <span data-slot="option-label">{opt.label}</span>
- <Show when={opt.description}>
- <span data-slot="option-description">{opt.description}</span>
- </Show>
- <Show when={picked()}>
- <Icon name="check-small" size="normal" />
- </Show>
- </button>
- )
- }}
- </For>
- <button
- data-slot="question-option"
- data-picked={customPicked()}
- onClick={() => selectOption(options().length)}
- >
- <span data-slot="option-label">{i18n.t("ui.messagePart.option.typeOwnAnswer")}</span>
- <Show when={!store.editing && input()}>
- <span data-slot="option-description">{input()}</span>
- </Show>
- <Show when={customPicked()}>
- <Icon name="check-small" size="normal" />
- </Show>
- </button>
- <Show when={store.editing}>
- <form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
- <input
- ref={(el) => setTimeout(() => el.focus(), 0)}
- type="text"
- data-slot="custom-input"
- placeholder={i18n.t("ui.question.custom.placeholder")}
- value={input()}
- onInput={(e) => {
- const inputs = [...store.custom]
- inputs[store.tab] = e.currentTarget.value
- setStore("custom", inputs)
- }}
- />
- <Button type="submit" variant="primary" size="small">
- {multi() ? i18n.t("ui.common.add") : i18n.t("ui.common.submit")}
- </Button>
- <Button type="button" variant="ghost" size="small" onClick={() => setStore("editing", false)}>
- {i18n.t("ui.common.cancel")}
- </Button>
- </form>
- </Show>
- </div>
- </div>
- </Show>
-
- <Show when={confirm()}>
- <div data-slot="question-review">
- <div data-slot="review-title">{i18n.t("ui.messagePart.review.title")}</div>
- <For each={questions()}>
- {(q, index) => {
- const value = () => store.answers[index()]?.join(", ") ?? ""
- const answered = () => Boolean(value())
- return (
- <div data-slot="review-item">
- <span data-slot="review-label">{q.question}</span>
- <span data-slot="review-value" data-answered={answered()}>
- {answered() ? value() : i18n.t("ui.question.review.notAnswered")}
- </span>
- </div>
- )
- }}
- </For>
- </div>
- </Show>
-
- <div data-slot="question-actions">
- <Button variant="ghost" size="small" onClick={reject}>
- {i18n.t("ui.common.dismiss")}
- </Button>
- <Show when={!single()}>
- <Show when={confirm()}>
- <Button variant="primary" size="small" onClick={submit}>
- {i18n.t("ui.common.submit")}
- </Button>
- </Show>
- <Show when={!confirm() && multi()}>
- <Button
- variant="secondary"
- size="small"
- onClick={() => selectTab(store.tab + 1)}
- disabled={(store.answers[store.tab]?.length ?? 0) === 0}
- >
- {i18n.t("ui.common.next")}
- </Button>
- </Show>
- </Show>
- </div>
- </div>
- )
-}