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:
parent
2e8e278441
commit
8b6a5f1651
10 changed files with 904 additions and 353 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue