fix(app): permissions and questions from child sessions (#15105)

Co-authored-by: adamelmore <2363879+adamdottv@users.noreply.github.com>
This commit is contained in:
Adam
2026-02-25 19:05:08 -06:00
committed by GitHub
parent 444178e079
commit b8337cddc4
8 changed files with 390 additions and 523 deletions

View File

@@ -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;

View File

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

View File

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