fix(permission): streamline external file approvals

Combine external-directory follow-ups into a single user-facing flow and delay permission prompts until typing settles so approvals are clearer and less disruptive.
This commit is contained in:
Kit Langton 2026-04-01 23:30:54 -04:00
commit 8b6a5f1651
10 changed files with 904 additions and 353 deletions

View file

@ -66,6 +66,7 @@ interface PromptInputProps {
shouldQueue?: () => boolean
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onInput?: () => void
onSubmit?: () => void
}
@ -853,6 +854,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const handleInput = () => {
props.onInput?.()
const rawParts = parseFromDOM()
const images = imageAttachments()
const cursorPosition = getCursorPosition(editorRef)

View file

@ -1,6 +1,7 @@
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { Spinner } from "@opencode-ai/ui/spinner"
import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
@ -160,6 +161,14 @@ export function SessionComposerRegion(props: {
</Show>
<Show when={!props.state.blocked()}>
<Show when={props.state.permissionQueued()}>
<div class="pb-2">
<div class="w-full rounded-md border border-border-warning/50 bg-background-base/70 px-4 py-2 text-text-weak flex items-center gap-2">
<Spinner class="size-3.5 text-icon-warning" />
<span>Permission request queued. Stop typing or submit to review.</span>
</div>
</div>
</Show>
<Show
when={prompt.ready()}
fallback={
@ -244,7 +253,11 @@ export function SessionComposerRegion(props: {
shouldQueue={props.followup?.queue}
onQueue={props.followup?.onQueue}
onAbort={props.followup?.onAbort}
onSubmit={props.onSubmit}
onInput={props.state.noteInput}
onSubmit={() => {
props.state.noteSubmit()
props.onSubmit()
}}
/>
</div>
</Show>

View file

@ -23,6 +23,7 @@ export const todoState = (input: {
}
const idle = { type: "idle" as const }
const TYPE_MS = 500
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
const params = useParams()
@ -36,12 +37,14 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
return sessionQuestionRequest(sync.data.session, sync.data.question, params.id)
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
const rawPermission = createMemo((): PermissionRequest | undefined => {
return sessionPermissionRequest(sync.data.session, sync.data.permission, params.id, (item) => {
return !permission.autoResponds(item, sdk.directory)
})
})
let typeTimer: number | undefined
const blocked = createMemo(() => {
const id = params.id
if (!id) return false
@ -118,6 +121,18 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
dock: todos().length > 0 && live(),
closing: false,
opening: false,
typing: false,
})
const permissionRequest = createMemo(() => {
const next = rawPermission()
if (!next) return
if (store.typing) return
return next
})
const permissionQueued = createMemo(() => {
return store.typing && !!rawPermission()
})
const permissionResponding = createMemo(() => {
@ -126,6 +141,26 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
return store.responding === perm.id
})
const clearTyping = () => {
if (typeTimer) window.clearTimeout(typeTimer)
typeTimer = undefined
}
const stopTyping = () => {
clearTyping()
if (store.typing) setStore("typing", false)
}
const noteInput = () => {
clearTyping()
if (!store.typing) setStore("typing", true)
typeTimer = window.setTimeout(() => {
stopTyping()
}, TYPE_MS)
}
const noteSubmit = stopTyping
const decide = (response: "once" | "always" | "reject") => {
const perm = permissionRequest()
if (!perm) return
@ -223,6 +258,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
),
)
onCleanup(stopTyping)
onCleanup(() => {
if (!timer) return
window.clearTimeout(timer)
@ -237,8 +274,11 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
blocked,
questionRequest,
permissionRequest,
permissionQueued,
permissionResponding,
decide,
noteInput,
noteSubmit,
todos,
dock: () => store.dock,
closing: () => store.closing,

View file

@ -1,65 +1,284 @@
import { For, Show } from "solid-js"
import { For, Show, createMemo, createSignal, onMount } from "solid-js"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { useLanguage } from "@/context/language"
import { useSync } from "@/context/sync"
type Decision = "once" | "always" | "reject"
const ORDER: Decision[] = ["once", "always", "reject"]
function text(input: unknown) {
return typeof input === "string" ? input : ""
}
function preview(input: string, limit: number = 6) {
const text = input.trim()
if (!text) return ""
let lines = 0
let idx = 0
while (idx < text.length) {
if (text[idx] === "\n") lines += 1
idx += 1
if (lines >= limit) break
}
return idx >= text.length ? text : text.slice(0, idx).trimEnd()
}
function parent(request: PermissionRequest) {
const raw = request.metadata?.parentDir
if (typeof raw === "string" && raw) return raw
const pattern = request.patterns[0]
if (!pattern) return ""
if (!pattern.endsWith("*")) return pattern
return pattern.slice(0, -1).replace(/[\\/]$/, "")
}
function remember(dir: string) {
return dir ? `Allow always remembers access to ${dir} for this session.` : ""
}
function external(tool: string, input: Record<string, unknown>, file: string, dir: string) {
const note = remember(dir)
if (tool === "write") {
return {
title: "Write file outside workspace",
hint: "This approval covers the external directory check and this write.",
file,
dir,
preview: preview(text(input.content)),
remember: note,
}
}
if (tool === "edit") {
return {
title: "Edit file outside workspace",
hint: "This approval covers the external directory check and this edit.",
file,
dir,
preview: preview(text(input.newString)),
remember: note,
}
}
if (tool === "apply_patch") {
return {
title: "Apply patch outside workspace",
hint: "This approval covers the external directory check and this patch.",
file,
dir,
preview: preview(text(input.patchText)),
remember: note,
}
}
if (tool === "read") {
return {
title: "Read file outside workspace",
hint: "This approval covers the external directory check and this read.",
file,
dir,
preview: "",
remember: note,
}
}
return {
title: dir ? "Access external directory" : "",
hint: "This action needs access outside the current workspace.",
file,
dir,
preview: "",
remember: note,
}
}
export function SessionPermissionDock(props: {
request: PermissionRequest
responding: boolean
onDecide: (response: "once" | "always" | "reject") => void
onDecide: (response: Decision) => void
}) {
const language = useLanguage()
const sync = useSync()
const [selected, setSelected] = createSignal<Decision>("once")
let root: HTMLDivElement | undefined
const part = createMemo(() => {
const tool = props.request.tool
if (!tool) return
return (sync.data.part[tool.messageID] ?? []).find((item) => item.type === "tool" && item.callID === tool.callID)
})
const input = createMemo(() => {
const next = part()
if (!next || next.type !== "tool") return {}
return next.state.input ?? {}
})
const info = createMemo(() => {
const dir = parent(props.request)
const data = input()
const file = text(data.filePath) || text(props.request.metadata?.filepath)
const current = part()
const tool = current && current.type === "tool" ? current.tool : ""
if (props.request.permission === "external_directory") {
const next = external(tool, data, file, dir)
return {
...next,
title: next.title || language.t("notification.permission.title"),
}
}
const toolDescription = () => {
const key = `settings.permissions.tool.${props.request.permission}.description`
const value = language.t(key as Parameters<typeof language.t>[0])
if (value === key) return ""
return value
return {
title: language.t("notification.permission.title"),
hint: value === key ? "" : value,
file,
dir,
preview: "",
remember: "",
}
})
const options = createMemo(() => [
{
value: "once" as const,
label: language.t("ui.permission.allowOnce"),
detail: info().hint,
},
{
value: "always" as const,
label: language.t("ui.permission.allowAlways"),
detail: info().remember,
},
{
value: "reject" as const,
label: language.t("ui.permission.deny"),
detail: "",
},
])
const choose = (value: Decision) => {
setSelected(value)
if (props.responding) return
props.onDecide(value)
}
const onKeyDown = (event: KeyboardEvent) => {
if (props.responding) return
if (event.defaultPrevented) return
if (event.metaKey || event.ctrlKey || event.altKey) return
if (event.key === "1") {
event.preventDefault()
choose("once")
return
}
if (event.key === "2") {
event.preventDefault()
choose("always")
return
}
if (event.key === "3") {
event.preventDefault()
choose("reject")
return
}
if (event.key === "Escape") {
event.preventDefault()
choose("reject")
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
const idx = ORDER.indexOf(selected())
setSelected(ORDER[(idx - 1 + ORDER.length) % ORDER.length])
return
}
if (event.key === "ArrowDown") {
event.preventDefault()
const idx = ORDER.indexOf(selected())
setSelected(ORDER[(idx + 1) % ORDER.length])
return
}
if (event.key === "Enter") {
event.preventDefault()
choose(selected())
}
}
onMount(() => {
requestAnimationFrame(() => root?.focus())
})
return (
<DockPrompt
kind="permission"
ref={(el) => {
root = el
root.tabIndex = -1
}}
onKeyDown={onKeyDown}
header={
<div data-slot="permission-row" data-variant="header">
<span data-slot="permission-icon">
<Icon name="warning" size="normal" />
</span>
<div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
<div data-slot="permission-header-title">{info().title}</div>
</div>
}
footer={
<>
<div />
<div data-slot="permission-footer-actions">
<Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
{language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="normal"
onClick={() => props.onDecide("always")}
disabled={props.responding}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
<div class="text-11-regular text-text-weak">1/2/3 choose</div>
<div class="text-11-regular text-text-weak text-right">enter confirm esc deny</div>
</>
}
>
<Show when={toolDescription()}>
<Show when={info().file}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-hint">{toolDescription()}</div>
<div class="flex flex-col gap-1 min-w-0">
<div class="text-12-medium text-text-weak uppercase tracking-[0.08em]">File</div>
<code class="text-12-regular text-text-base break-all">{info().file}</code>
</div>
</div>
</Show>
<Show when={props.request.patterns.length > 0}>
<Show when={info().dir}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div class="flex flex-col gap-1 min-w-0">
<div class="text-12-medium text-text-weak uppercase tracking-[0.08em]">Directory</div>
<code class="text-12-regular text-text-base break-all">{info().dir}</code>
</div>
</div>
</Show>
<Show when={info().preview}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div class="flex flex-col gap-1 min-w-0">
<div class="text-12-medium text-text-weak uppercase tracking-[0.08em]">Preview</div>
<pre class="m-0 rounded-md bg-background-base/70 px-3 py-2 overflow-x-auto text-12-regular text-text-base whitespace-pre-wrap break-words">
{info().preview}
</pre>
</div>
</div>
</Show>
<Show when={!info().file && !info().dir && props.request.patterns.length > 0}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-patterns">
@ -69,6 +288,44 @@ export function SessionPermissionDock(props: {
</div>
</div>
</Show>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div class="flex w-full flex-col gap-2">
<For each={options()}>
{(option, index) => (
<Button
variant={
selected() === option.value
? option.value === "once"
? "primary"
: option.value === "always"
? "secondary"
: "ghost"
: "ghost"
}
size="normal"
onMouseEnter={() => setSelected(option.value)}
onClick={() => choose(option.value)}
disabled={props.responding}
class="w-full justify-start px-3 py-2 h-auto"
>
<span class="flex flex-col items-start gap-0.5 text-left min-w-0">
<span class="inline-flex items-center gap-2">
<Show when={props.responding && selected() === option.value}>
<Spinner class="size-3.5" />
</Show>
{`${index() + 1}. ${option.label}`}
</span>
<Show when={option.detail}>
<span class="text-11-regular text-text-weak whitespace-normal">{option.detail}</span>
</Show>
</span>
</Button>
)}
</For>
</div>
</div>
</DockPrompt>
)
}

View file

@ -4,6 +4,7 @@ import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
@ -230,6 +231,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}))
const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
const replying = createMemo(() => replyMutation.isPending)
const rejecting = createMemo(() => rejectMutation.isPending)
const reply = async (answers: QuestionAnswer[]) => {
if (sending()) return
@ -449,7 +452,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
footer={
<>
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
{language.t("ui.common.dismiss")}
<span class="inline-flex items-center gap-2">
<Show when={rejecting()}>
<Spinner class="size-3.5" />
</Show>
{language.t("ui.common.dismiss")}
</span>
</Button>
<div data-slot="question-footer-actions">
<Show when={store.tab > 0}>
@ -464,7 +472,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
onClick={next}
aria-keyshortcuts="Meta+Enter Control+Enter"
>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
<span class="inline-flex items-center gap-2">
<Show when={replying()}>
<Spinner class="size-3.5" />
</Show>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</span>
</Button>
</div>
</>