diff options
| author | Adam <[email protected]> | 2026-02-25 19:05:08 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-26 01:05:08 +0000 |
| commit | b8337cddc4269ba6e72f74c1e1f39aae41f56af3 (patch) | |
| tree | 178a80a21af1a24f3568a0ef15e735a5d537c16f /packages/ui | |
| parent | 444178e079fb41ba2149a1cdfdd3040593715d70 (diff) | |
| download | opencode-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')
| -rw-r--r-- | packages/ui/src/components/message-part.css | 101 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 325 | ||||
| -rw-r--r-- | packages/ui/src/context/data.tsx | 34 |
3 files changed, 4 insertions, 456 deletions
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index f063076e0..bea33ff54 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -660,105 +660,6 @@ [data-component="tool-part-wrapper"] { width: 100%; - - &[data-permission="true"], - &[data-question="true"] { - position: sticky; - top: calc(2px + var(--sticky-header-height, 40px)); - bottom: 0px; - z-index: 20; - border-radius: 6px; - border: none; - box-shadow: var(--shadow-xs-border-base); - background-color: var(--surface-raised-base); - overflow: visible; - overflow-anchor: none; - - & > *:first-child { - border-top-left-radius: 6px; - border-top-right-radius: 6px; - overflow: hidden; - } - - & > *:last-child { - border-bottom-left-radius: 6px; - border-bottom-right-radius: 6px; - overflow: hidden; - } - - [data-component="collapsible"] { - border: none; - } - - [data-component="card"] { - border: none; - } - } - - &[data-permission="true"] { - &::before { - content: ""; - position: absolute; - inset: -1.5px; - top: -5px; - border-radius: 7.5px; - border: 1.5px solid transparent; - background: - linear-gradient(var(--background-base) 0 0) padding-box, - conic-gradient( - from var(--border-angle), - transparent 0deg, - transparent 0deg, - var(--border-warning-strong, var(--border-warning-selected)) 300deg, - var(--border-warning-base) 360deg - ) - border-box; - animation: chase-border 2.5s linear infinite; - pointer-events: none; - z-index: -1; - } - } - - &[data-question="true"] { - background: var(--background-base); - border: 1px solid var(--border-weak-base); - } -} - -@property --border-angle { - syntax: "<angle>"; - initial-value: 0deg; - inherits: false; -} - -@keyframes chase-border { - from { - --border-angle: 0deg; - } - - to { - --border-angle: 360deg; - } -} - -[data-component="permission-prompt"] { - display: flex; - flex-direction: column; - padding: 8px 12px; - background-color: var(--surface-raised-strong); - border-radius: 0 0 6px 6px; - - [data-slot="permission-actions"] { - display: flex; - align-items: center; - gap: 8px; - justify-content: flex-end; - - [data-component="button"] { - padding-left: 12px; - padding-right: 12px; - } - } } [data-component="dock-prompt"][data-kind="permission"] { @@ -873,7 +774,7 @@ } } -:is([data-component="question-prompt"], [data-component="dock-prompt"][data-kind="question"]) { +[data-component="dock-prompt"][data-kind="question"] { position: relative; display: flex; flex-direction: column; 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> - ) -} diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 2c44763f5..e116199eb 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -1,14 +1,4 @@ -import type { - Message, - Session, - Part, - FileDiff, - SessionStatus, - PermissionRequest, - QuestionRequest, - QuestionAnswer, - ProviderListResponse, -} from "@opencode-ai/sdk/v2" +import type { Message, Session, Part, FileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -24,12 +14,6 @@ type Data = { session_diff_preload?: { [sessionID: string]: PreloadMultiFileDiffResult<any>[] } - permission?: { - [sessionID: string]: PermissionRequest[] - } - question?: { - [sessionID: string]: QuestionRequest[] - } message: { [sessionID: string]: Message[] } @@ -38,16 +22,6 @@ type Data = { } } -export type PermissionRespondFn = (input: { - sessionID: string - permissionID: string - response: "once" | "always" | "reject" -}) => void - -export type QuestionReplyFn = (input: { requestID: string; answers: QuestionAnswer[] }) => void - -export type QuestionRejectFn = (input: { requestID: string }) => void - export type NavigateToSessionFn = (sessionID: string) => void export type SessionHrefFn = (sessionID: string) => string @@ -57,9 +31,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ init: (props: { data: Data directory: string - onPermissionRespond?: PermissionRespondFn - onQuestionReply?: QuestionReplyFn - onQuestionReject?: QuestionRejectFn onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn }) => { @@ -70,9 +41,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ get directory() { return props.directory }, - respondToPermission: props.onPermissionRespond, - replyToQuestion: props.onQuestionReply, - rejectQuestion: props.onQuestionReject, navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref, } |
