Inline permission approvals in tool calls

This commit is contained in:
Shantur Rathore
2025-11-16 02:30:12 +00:00
parent 2b6597ad00
commit c4e76aaac4
16 changed files with 572 additions and 106 deletions

View File

@@ -6,6 +6,8 @@ import MessagePart from "./message-part"
interface MessageItemProps {
message: Message
messageInfo?: MessageInfo
instanceId: string
sessionId: string
isQueued?: boolean
parts?: ClientPart[]
onRevert?: (messageId: string) => void
@@ -139,7 +141,14 @@ export default function MessageItem(props: MessageItemProps) {
</div>
</Show>
<For each={messageParts()}>{(part) => <MessagePart part={part} messageType={props.message.type} />}</For>
<For each={messageParts()}>{(part) => (
<MessagePart
part={part}
messageType={props.message.type}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
)}</For>
</div>
<Show when={props.message.status === "sending"}>

View File

@@ -11,6 +11,8 @@ type ToolCallPart = Extract<ClientPart, { type: "tool" }>
interface MessagePartProps {
part: ClientPart
messageType?: "user" | "assistant"
instanceId: string
sessionId: string
}
export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme()
@@ -71,7 +73,12 @@ export default function MessagePart(props: MessagePartProps) {
</Match>
<Match when={partType() === "tool"}>
<ToolCall toolCall={props.part as ToolCallPart} toolCallId={props.part?.id} />
<ToolCall
toolCall={props.part as ToolCallPart}
toolCallId={props.part?.id}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
</Match>

View File

@@ -615,6 +615,8 @@ export default function MessageStream(props: MessageStreamProps) {
<MessageItem
message={item.message}
messageInfo={item.messageInfo}
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={item.isQueued}
parts={item.combinedParts}
onRevert={props.onRevert}
@@ -665,6 +667,8 @@ export default function MessageStream(props: MessageStreamProps) {
messageId={item.messageId}
messageVersion={item.messageVersion}
partVersion={item.partVersion}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
</div>
)

View File

@@ -208,9 +208,13 @@ const SessionList: Component<SessionListProps> = (props) => {
const title = () => session()?.title || "Untitled"
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
const statusLabel = () => formatSessionStatus(status())
const pendingPermission = () => Boolean(session()?.pendingPermission)
const statusClassName = () => (pendingPermission() ? "session-permission" : `session-${status()}`)
const statusText = () => (pendingPermission() ? "Needs Permission" : statusLabel())
return (
<div class="session-list-item group">
<div class="session-list-item group">
<button
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
onClick={() => selectSession(rowProps.sessionId)}
@@ -239,9 +243,9 @@ const SessionList: Component<SessionListProps> = (props) => {
</Show>
</div>
<div class="session-item-row session-item-meta">
<span class={`status-indicator session-status session-status-list session-${status()}`}>
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
<span class="status-dot" />
{statusLabel()}
{statusText()}
</span>
<div class="session-item-actions">
<span

View File

@@ -4,7 +4,7 @@ import type { Attachment } from "../../types/attachment"
import type { ClientPart } from "../../types/message"
import MessageStream from "../message-stream"
import PromptInput from "../prompt-input"
import { instances, getActivePermission, sendPermissionResponse } from "../../stores/instances"
import { instances } from "../../stores/instances"
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession } from "../../stores/sessions"
interface SessionViewProps {
@@ -106,39 +106,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
}
}
const activePermission = createMemo(() => getActivePermission(props.instanceId))
async function handlePermissionResponse(response: "once" | "always" | "reject") {
const permission = activePermission()
if (!permission) return
try {
await sendPermissionResponse(props.instanceId, props.sessionId, permission.id, response)
} catch (error) {
console.error("Failed to send permission response:", error)
}
}
createEffect(() => {
const permission = activePermission()
if (!permission) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault()
handlePermissionResponse("once")
} else if (event.key === "a" || event.key === "A") {
event.preventDefault()
handlePermissionResponse("always")
} else if (event.key === "d" || event.key === "D") {
event.preventDefault()
handlePermissionResponse("reject")
}
}
document.addEventListener("keydown", handleKeyDown)
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
})
return (
<Show
@@ -162,39 +129,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
onFork={handleFork}
/>
<Show when={activePermission()}>
{(permission) => (
<div class="permission-dialog border-2 border-[var(--status-warning)] bg-surface-secondary p-4 mx-4 mb-4 rounded-lg shadow-lg">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<div class="w-6 h-6 bg-[var(--status-warning)] rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-[var(--text-inverted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
</div>
<div class="flex-1">
<div class="mb-2">
<span class="font-semibold text-primary">Permission Required</span>
<span class="ml-2 font-mono text-sm bg-surface-secondary border border-base rounded px-1.5 py-0.5">{permission().type}</span>
</div>
<div class="bg-surface-code p-3 rounded border mb-3">
<code class="text-sm text-primary">{permission().title}</code>
</div>
<div class="flex gap-2 text-sm">
<kbd class="kbd">Enter</kbd>
<span class="text-muted">Accept once</span>
<kbd class="kbd ml-4">a</kbd>
<span class="text-muted">Accept always</span>
<kbd class="kbd ml-4">d</kbd>
<span class="text-muted">Deny</span>
</div>
</div>
</div>
</div>
)}
</Show>
<PromptInput
instanceId={props.instanceId}
instanceFolder={props.instanceFolder}

View File

@@ -1,4 +1,4 @@
import { createSignal, Show, For, createEffect, onCleanup } from "solid-js"
import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js"
import { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state"
import { Markdown } from "./markdown"
import { ToolCallDiffViewer } from "./diff-viewer"
@@ -8,6 +8,7 @@ import { isRenderableDiffText } from "../lib/diff-utils"
import { getToolRenderCache, setToolRenderCache } from "../lib/tool-render-cache"
import { useConfig } from "../stores/preferences"
import type { DiffViewMode } from "../stores/preferences"
import { sendPermissionResponse } from "../stores/instances"
import type { TextPart, SDKPart, ClientPart } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -81,6 +82,8 @@ interface ToolCallProps {
messageId?: string
messageVersion?: number
partVersion?: number
instanceId: string
sessionId: string
}
function getToolIcon(tool: string): string {
@@ -183,11 +186,22 @@ export default function ToolCall(props: ToolCallProps) {
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
const expanded = () => isToolCallExpanded(toolCallId())
const [initializedId, setInitializedId] = createSignal<string | null>(null)
const pendingPermission = createMemo(() => props.toolCall.pendingPermission)
const permissionDetails = createMemo(() => pendingPermission()?.permission)
const isPermissionActive = createMemo(() => pendingPermission()?.active === true)
const activePermissionKey = createMemo(() => {
const permission = permissionDetails()
return permission && isPermissionActive() ? permission.id : ""
})
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
const [permissionError, setPermissionError] = createSignal<string | null>(null)
let scrollContainerRef: HTMLDivElement | undefined
let toolCallRootRef: HTMLDivElement | undefined
const handleScrollRendered = () => {
let scrollContainerRef: HTMLDivElement | undefined
const handleScrollRendered = () => {
const id = toolCallId()
if (!id || !scrollContainerRef) return
restoreScrollState(id, scrollContainerRef)
@@ -221,6 +235,23 @@ export default function ToolCall(props: ToolCallProps) {
setInitializedId(id)
})
createEffect(() => {
if (!pendingPermission()) return
const id = toolCallId()
if (!id) return
setToolCallExpanded(id, true)
})
createEffect(() => {
const permission = permissionDetails()
if (!permission) {
setPermissionSubmitting(false)
setPermissionError(null)
} else {
setPermissionError(null)
}
})
// Cleanup cache entry when component unmounts or toolCallId changes
createEffect(() => {
const id = toolCallId()
@@ -245,6 +276,34 @@ export default function ToolCall(props: ToolCallProps) {
})
})
createEffect(() => {
const activeKey = activePermissionKey()
if (!activeKey) return
requestAnimationFrame(() => {
toolCallRootRef?.scrollIntoView({ block: "center", behavior: "smooth" })
})
})
createEffect(() => {
const activeKey = activePermissionKey()
if (!activeKey) return
const handler = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault()
handlePermissionResponse("once")
} else if (event.key === "a" || event.key === "A") {
event.preventDefault()
handlePermissionResponse("always")
} else if (event.key === "d" || event.key === "D") {
event.preventDefault()
handlePermissionResponse("reject")
}
}
document.addEventListener("keydown", handler)
onCleanup(() => document.removeEventListener("keydown", handler))
})
const statusIcon = () => {
const status = props.toolCall?.state?.status || ""
switch (status) {
@@ -266,6 +325,11 @@ export default function ToolCall(props: ToolCallProps) {
return `tool-call-status-${status}`
}
const combinedStatusClass = () => {
const base = statusClass()
return pendingPermission() ? `${base} tool-call-awaiting-permission` : base
}
function toggle() {
toggleToolCallExpanded(toolCallId())
}
@@ -301,6 +365,24 @@ export default function ToolCall(props: ToolCallProps) {
}
}
async function handlePermissionResponse(response: "once" | "always" | "reject") {
const permission = permissionDetails()
if (!permission || !isPermissionActive()) {
return
}
setPermissionSubmitting(true)
setPermissionError(null)
try {
const sessionId = permission.sessionID || props.sessionId
await sendPermissionResponse(props.instanceId, sessionId, permission.id, response)
} catch (error) {
console.error("Failed to send permission response:", error)
setPermissionError(error instanceof Error ? error.message : "Unable to update permission")
} finally {
setPermissionSubmitting(false)
}
}
const getTodoTitle = () => {
const state = props.toolCall?.state || {}
if (state.status !== "completed") return "Plan"
@@ -424,10 +506,11 @@ export default function ToolCall(props: ToolCallProps) {
return renderMarkdownTool(toolName, state)
}
function renderDiffTool(payload: DiffPayload) {
function renderDiffTool(payload: DiffPayload, options?: { cacheKeySuffix?: string; disableScrollTracking?: boolean; label?: string }) {
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
const toolbarLabel = relativePath ? `Diff · ${relativePath}` : "Diff"
const cacheKey = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
const cacheKeyBase = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
const cacheKey = options?.cacheKeySuffix ? `${cacheKeyBase}${options.cacheKeySuffix}` : cacheKeyBase
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
const themeKey = isDark() ? "dark" : "light"
@@ -453,15 +536,21 @@ export default function ToolCall(props: ToolCallProps) {
// Cache will be updated by the diff viewer component itself
// We'll capture HTML from the rendered component
}
handleScrollRendered()
if (!options?.disableScrollTracking) {
handleScrollRendered()
}
}
return (
<div
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
ref={(element) => initializeScrollContainer(element)}
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
ref={(element) => {
if (options?.disableScrollTracking) return
initializeScrollContainer(element)
}}
onScroll={options?.disableScrollTracking ? undefined : (event) => updateScrollState(toolCallId(), event.currentTarget)}
>
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
<div class="tool-call-diff-toggle">
@@ -802,11 +891,103 @@ export default function ToolCall(props: ToolCallProps) {
return null
}
const renderPermissionBlock = () => {
const permission = permissionDetails()
if (!permission) return null
const active = isPermissionActive()
const metadata = (permission.metadata ?? {}) as Record<string, unknown>
const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null
const diffPathRaw = (() => {
if (typeof metadata.filePath === "string") {
return metadata.filePath as string
}
if (typeof metadata.path === "string") {
return metadata.path as string
}
return undefined
})()
const diffPayload = diffValue && diffValue.trim().length > 0 ? { diffText: diffValue, filePath: diffPathRaw } : null
return (
<div class={`tool-call-permission ${active ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
<div class="tool-call-permission-header">
<span class="tool-call-permission-label">{active ? "Permission Required" : "Permission Queued"}</span>
<span class="tool-call-permission-type">{permission.type}</span>
</div>
<div class="tool-call-permission-body">
<div class="tool-call-permission-title">
<code>{permission.title}</code>
</div>
<Show when={diffPayload}>
{(payload) => (
<div class="tool-call-permission-diff">
{renderDiffTool(payload(), {
cacheKeySuffix: "::permission",
disableScrollTracking: true,
label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff",
})}
</div>
)}
</Show>
<Show
when={active}
fallback={<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>}
>
<div class="tool-call-permission-actions">
<div class="tool-call-permission-buttons">
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting()}
onClick={() => handlePermissionResponse("once")}
>
Allow Once
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting()}
onClick={() => handlePermissionResponse("always")}
>
Always Allow
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting()}
onClick={() => handlePermissionResponse("reject")}
>
Deny
</button>
</div>
<div class="tool-call-permission-shortcuts">
<kbd class="kbd">Enter</kbd>
<span>Allow once</span>
<kbd class="kbd">A</kbd>
<span>Always allow</span>
<kbd class="kbd">D</kbd>
<span>Deny</span>
</div>
</div>
<Show when={permissionError()}>
<div class="tool-call-permission-error">{permissionError()}</div>
</Show>
</Show>
</div>
</div>
)
}
const toolName = () => props.toolCall?.tool || ""
const status = () => props.toolCall?.state?.status || ""
return (
<div class={`tool-call ${statusClass()}`}>
<div
ref={(element) => {
toolCallRootRef = element || undefined
}}
class={`tool-call ${combinedStatusClass()}`}
>
<button class="tool-call-header" onClick={toggle} aria-expanded={expanded()}>
<span class="tool-call-icon">{expanded() ? "▼" : "▶"}</span>
<span class="tool-call-emoji">{getToolIcon(toolName())}</span>
@@ -819,7 +1000,9 @@ export default function ToolCall(props: ToolCallProps) {
{renderToolBody()}
{renderError()}
<Show when={status() === "pending"}>
{renderPermissionBlock()}
<Show when={status() === "pending" && !pendingPermission()}>
<div class="tool-call-pending-message">
<span class="spinner-small"></span>
<span>Waiting for permission...</span>

View File

@@ -1,6 +1,7 @@
import { createSignal } from "solid-js"
import type { Instance, LogEntry } from "../types/instance"
import type { Permission } from "@opencode-ai/sdk"
import type { ClientPart, Message } from "../types/message"
import { sdkManager } from "../lib/sdk-manager"
import { sseManager } from "../lib/sse-manager"
import {
@@ -12,6 +13,8 @@ import {
} from "./sessions"
import { fetchCommands, clearCommands } from "./commands"
import { preferences, updateLastUsedBinary } from "./preferences"
import { computeDisplayParts } from "./session-messages"
import { withSession, setSessionPendingPermission } from "./session-state"
import { setHasInstances } from "./ui"
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
@@ -22,6 +25,7 @@ const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boole
// Permission queue management per instance
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, Permission[]>>(new Map())
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
const permissionSessionCounts = new Map<string, Map<string, number>>()
interface DisconnectedInstanceInfo {
id: string
folder: string
@@ -141,6 +145,7 @@ function removeInstance(id: string) {
removeLogContainer(id)
clearCommands(id)
clearPermissionQueue(id)
if (activeInstanceId() === id) {
setActiveInstanceId(nextActiveId)
@@ -260,30 +265,73 @@ function clearLogs(id: string) {
// Permission management functions
function getPermissionQueue(instanceId: string): Permission[] {
return permissionQueues().get(instanceId) ?? []
const queue = permissionQueues().get(instanceId)
if (!queue) {
return []
}
return queue
}
function getPermissionQueueLength(instanceId: string): number {
return getPermissionQueue(instanceId).length
}
function incrementSessionPendingCount(instanceId: string, sessionId: string): void {
let sessionCounts = permissionSessionCounts.get(instanceId)
if (!sessionCounts) {
sessionCounts = new Map()
permissionSessionCounts.set(instanceId, sessionCounts)
}
const current = sessionCounts.get(sessionId) ?? 0
sessionCounts.set(sessionId, current + 1)
}
function decrementSessionPendingCount(instanceId: string, sessionId: string): number {
const sessionCounts = permissionSessionCounts.get(instanceId)
if (!sessionCounts) return 0
const current = sessionCounts.get(sessionId) ?? 0
if (current <= 1) {
sessionCounts.delete(sessionId)
if (sessionCounts.size === 0) {
permissionSessionCounts.delete(instanceId)
}
return 0
}
const nextValue = current - 1
sessionCounts.set(sessionId, nextValue)
return nextValue
}
function clearSessionPendingCounts(instanceId: string): void {
const sessionCounts = permissionSessionCounts.get(instanceId)
if (!sessionCounts) return
for (const sessionId of sessionCounts.keys()) {
setSessionPendingPermission(instanceId, sessionId, false)
}
permissionSessionCounts.delete(instanceId)
}
function addPermissionToQueue(instanceId: string, permission: Permission): void {
let inserted = false
setPermissionQueues((prev) => {
const next = new Map(prev)
const queue = next.get(instanceId) ?? []
// Check if permission already exists
if (queue.some(p => p.id === permission.id)) {
return next // Don't add duplicate
if (queue.some((p) => p.id === permission.id)) {
return next
}
// Add to queue and sort by creation time to maintain order
const updatedQueue = [...queue, permission].sort((a, b) => a.time.created - b.time.created)
next.set(instanceId, updatedQueue)
inserted = true
return next
})
// Set as active if no active permission
if (!inserted) {
return
}
setActivePermissionId((prev) => {
const next = new Map(prev)
if (!next.get(instanceId)) {
@@ -291,6 +339,13 @@ function addPermissionToQueue(instanceId: string, permission: Permission): void
}
return next
})
const sessionId = getPermissionSessionId(permission)
incrementSessionPendingCount(instanceId, sessionId)
setSessionPendingPermission(instanceId, sessionId, true)
const isActive = getActivePermission(instanceId)?.id === permission.id
attachPermissionToToolPart(instanceId, permission, isActive)
}
function getActivePermission(instanceId: string): Permission | null {
@@ -302,30 +357,53 @@ function getActivePermission(instanceId: string): Permission | null {
}
function removePermissionFromQueue(instanceId: string, permissionId: string): void {
let updatedQueue: Permission[] = []
let removedPermission: Permission | null = null
setPermissionQueues((prev) => {
const next = new Map(prev)
const queue = next.get(instanceId) ?? []
updatedQueue = queue.filter(p => p.id !== permissionId)
if (updatedQueue.length > 0) {
next.set(instanceId, updatedQueue)
const filtered: Permission[] = []
for (const item of queue) {
if (item.id === permissionId) {
removedPermission = item
continue
}
filtered.push(item)
}
if (filtered.length > 0) {
next.set(instanceId, filtered)
} else {
next.delete(instanceId)
}
return next
})
const updatedQueue = getPermissionQueue(instanceId)
setActivePermissionId((prev) => {
const next = new Map(prev)
const activeId = next.get(instanceId)
if (activeId === permissionId) {
// Set the next permission in queue as active, or null if queue is empty
const nextPermission = updatedQueue.length > 0 ? updatedQueue[0] : null
const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as Permission) : null
next.set(instanceId, nextPermission?.id ?? null)
}
return next
})
const removed = removedPermission
if (removed) {
clearPermissionFromToolPart(instanceId, removed)
const removedSessionId = getPermissionSessionId(removed)
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0)
}
const nextActivePermission = getActivePermission(instanceId)
if (nextActivePermission) {
attachPermissionToToolPart(instanceId, nextActivePermission, true)
}
}
function clearPermissionQueue(instanceId: string): void {
@@ -339,6 +417,95 @@ function clearPermissionQueue(instanceId: string): void {
next.delete(instanceId)
return next
})
clearSessionPendingCounts(instanceId)
}
function getPermissionSessionId(permission: Permission): string {
return (permission as any).sessionID
}
function findToolPartForPermission(message: Message, permission: Permission): ClientPart | null {
const expectedCallId = permission.callID
for (const part of message.parts) {
if (part.type !== "tool") continue
const toolCallId = (part as any).callID
if (expectedCallId) {
if (toolCallId === expectedCallId) {
return part as ClientPart
}
if (!toolCallId && (part.id === expectedCallId || part.messageID === permission.messageID)) {
return part as ClientPart
}
continue
}
if ((toolCallId && toolCallId === permission.id) || part.id === permission.id || part.messageID === permission.messageID) {
return part as ClientPart
}
}
return null
}
function mutateToolPartPermission(
instanceId: string,
permission: Permission,
mutator: (part: ClientPart, message: Message) => boolean,
): void {
const permissionSessionId = getPermissionSessionId(permission)
withSession(instanceId, permissionSessionId, (session) => {
const message = session.messages.find((msg) => msg.id === permission.messageID)
if (!message) return
const targetPart = findToolPartForPermission(message, permission)
if (!targetPart) return
const changed = mutator(targetPart, message)
if (!changed) return
const nextPartVersion = typeof targetPart.version === "number" ? targetPart.version + 1 : 1
targetPart.version = nextPartVersion
message.version = (message.version ?? 0) + 1
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
})
}
function attachPermissionToToolPart(instanceId: string, permission: Permission, active: boolean): void {
mutateToolPartPermission(instanceId, permission, (part) => {
const existing = part.pendingPermission
if (existing && existing.permission.id === permission.id && existing.active === active) {
return false
}
part.pendingPermission = { permission, active }
return true
})
}
function clearPermissionFromToolPart(instanceId: string, permission: Permission): void {
mutateToolPartPermission(instanceId, permission, (part) => {
if (!part.pendingPermission || part.pendingPermission.permission.id !== permission.id) {
return false
}
delete part.pendingPermission
return true
})
}
function refreshPermissionsForSession(instanceId: string, sessionId: string): void {
const queue = getPermissionQueue(instanceId)
if (queue.length === 0) {
setSessionPendingPermission(instanceId, sessionId, false)
return
}
const activeId = activePermissionId().get(instanceId)
for (const permission of queue) {
if (getPermissionSessionId(permission) !== sessionId) continue
const isActive = permission.id === activeId
attachPermissionToToolPart(instanceId, permission, isActive)
}
const pendingCount = permissionSessionCounts.get(instanceId)?.get(sessionId) ?? 0
setSessionPendingPermission(instanceId, sessionId, pendingCount > 0)
}
async function sendPermissionResponse(
@@ -422,6 +589,7 @@ export {
getActivePermission,
removePermissionFromQueue,
clearPermissionQueue,
refreshPermissionsForSession,
sendPermissionResponse,
disconnectedInstance,
acknowledgeDisconnectedInstance,

View File

@@ -1,7 +1,7 @@
import type { Session } from "../types/session"
import type { Message } from "../types/message"
import { instances } from "./instances"
import { instances, refreshPermissionsForSession } from "./instances"
import { preferences, setAgentModelPreference } from "./preferences"
import { setSessionCompactionState } from "./session-compaction"
import {
@@ -608,9 +608,11 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
return next
})
}
updateSessionInfo(instanceId, sessionId)
refreshPermissionsForSession(instanceId, sessionId)
}
updateSessionInfo(instanceId, sessionId)
}
export {
createSession,

View File

@@ -15,7 +15,7 @@ import type {
import { showToastNotification, ToastVariant } from "../lib/notifications"
import { preferences } from "./preferences"
import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances"
import { instances, addPermissionToQueue, removePermissionFromQueue, refreshPermissionsForSession } from "./instances"
import {
sessions,
setSessions,
@@ -211,6 +211,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
})
updateSessionInfo(instanceId, part.sessionID)
refreshPermissionsForSession(instanceId, part.sessionID)
} else if (event.type === "message.updated") {
const info = event.properties?.info
if (!info) return
@@ -308,6 +309,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
})
updateSessionInfo(instanceId, info.sessionID)
refreshPermissionsForSession(instanceId, info.sessionID)
}
}

View File

@@ -127,6 +127,13 @@ function setSessionCompactionState(instanceId: string, sessionId: string, isComp
})
}
function setSessionPendingPermission(instanceId: string, sessionId: string, pending: boolean): void {
withSession(instanceId, sessionId, (session) => {
if (session.pendingPermission === pending) return
session.pendingPermission = pending
})
}
function setActiveSession(instanceId: string, sessionId: string): void {
setActiveSessionId((prev) => {
const next = new Map(prev)
@@ -237,7 +244,9 @@ export {
pruneDraftPrompts,
withSession,
setSessionCompactionState,
setSessionPendingPermission,
setActiveSession,
setActiveParentSession,
clearActiveParentSession,
getActiveSession,

View File

@@ -237,16 +237,128 @@
line-height: var(--line-height-tight);
}
.tool-call-awaiting-permission {
border-left-color: var(--status-warning);
}
.tool-call-permission {
@apply flex flex-col gap-3;
border: 2px solid var(--status-warning);
border-radius: 0;
margin: 0;
padding: 1rem 1.25rem;
background-color: var(--message-tool-bg);
}
.tool-call-permission-header {
@apply flex items-center justify-between gap-3;
}
.tool-call-permission-label {
@apply font-semibold text-sm;
color: var(--text-primary);
}
.tool-call-permission-type {
font-family: var(--font-family-mono);
font-size: 12px;
padding: 2px 6px;
border-radius: 0.375rem;
border: 1px solid var(--border-base);
background-color: var(--surface-code);
}
.tool-call-permission-title code {
display: block;
font-size: 13px;
color: var(--text-primary);
background-color: var(--surface-code);
border: 1px solid var(--border-base);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
word-break: break-word;
}
.tool-call-permission-actions {
@apply flex items-center justify-between gap-3 flex-wrap;
margin-top: 0.75rem;
}
.tool-call-permission-buttons {
@apply flex flex-wrap gap-2;
}
.tool-call-permission-button {
background-color: var(--surface-base);
border: 1px solid var(--status-warning);
color: var(--text-secondary);
padding: 0.4rem 1.05rem;
border-radius: 0.5rem;
font-size: 0.8rem;
font-weight: var(--font-weight-medium);
line-height: 1.15;
transition: transform 0.15s ease, color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 1.75rem;
}
.tool-call-permission-button:hover:not(:disabled) {
background-color: var(--surface-hover);
border-color: var(--status-warning);
color: var(--status-warning);
}
.tool-call-permission-button:active:not(:disabled) {
transform: scale(0.97);
}
.tool-call-permission-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tool-call-permission-shortcuts {
@apply flex items-center gap-2 text-xs text-muted;
}
.tool-call-permission-shortcuts .kbd {
margin-right: 0.25rem;
}
.tool-call-permission-queued-text {
@apply text-sm text-muted;
}
.tool-call-permission-error {
@apply text-sm;
color: var(--status-error);
margin-top: 0.5rem;
}
.tool-call-permission-diff {
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.tool-call-permission-diff .tool-call-diff-shell {
margin: 0;
}
.tool-call-diff-viewer .diff-line-old-num,
.tool-call-diff-viewer .diff-line-new-num,
.tool-call-diff-viewer .diff-line-num {
width: auto !important;
min-width: 4ch;
padding-left: 0.5rem;
padding-right: 0.5rem;
white-space: nowrap;
color: var(--text-muted);
font-size: var(--font-size-xs);
}
.tool-call-markdown .markdown-code-block {
margin: 0;
border-radius: 0;
}
.tool-call-markdown .markdown-code-block {
margin: 0;
border: none;

View File

@@ -194,6 +194,11 @@
--session-status-dot: var(--session-status-idle-fg);
}
.status-indicator.session-status.session-permission {
color: var(--session-status-permission-fg);
--session-status-dot: var(--session-status-permission-fg);
}
.status-indicator.session-status .status-dot {
background-color: var(--session-status-dot);
}
@@ -215,6 +220,10 @@
background-color: var(--session-status-idle-bg);
}
.status-indicator.session-status.session-permission.session-status-list {
background-color: var(--session-status-permission-bg);
}
.status-indicator.session-status-list {
font-size: 0.65rem;
text-transform: uppercase;

View File

@@ -246,6 +246,11 @@ session-sidebar-controls .selector-trigger-primary {
--session-status-dot: var(--session-status-idle-fg);
}
.status-indicator.session-status.session-permission {
color: var(--session-status-permission-fg);
--session-status-dot: var(--session-status-permission-fg);
}
.status-indicator.session-status .status-dot {
background-color: var(--session-status-dot);
}
@@ -267,6 +272,10 @@ session-sidebar-controls .selector-trigger-primary {
background-color: var(--session-status-idle-bg);
}
.status-indicator.session-status.session-permission.session-status-list {
background-color: var(--session-status-permission-bg);
}
.status-indicator.session-status-list {
font-size: 0.65rem;
text-transform: uppercase;

View File

@@ -42,6 +42,8 @@
--session-status-compacting-bg: rgba(109, 40, 217, 0.18);
--session-status-idle-fg: #15803d;
--session-status-idle-bg: rgba(22, 163, 74, 0.16);
--session-status-permission-fg: #c2410c;
--session-status-permission-bg: rgba(251, 191, 36, 0.25);
--list-item-highlight-bg: rgba(0, 102, 255, 0.1);
--list-item-highlight-border: rgba(0, 102, 255, 0.25);
--attachment-chip-bg: rgba(0, 102, 255, 0.1);
@@ -188,6 +190,8 @@
--session-status-compacting-bg: rgba(192, 132, 252, 0.28);
--session-status-idle-fg: #4ade80;
--session-status-idle-bg: rgba(74, 222, 128, 0.22);
--session-status-permission-fg: #fbbf24;
--session-status-permission-bg: rgba(251, 191, 36, 0.35);
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
--attachment-chip-bg: rgba(0, 128, 255, 0.1);
@@ -338,6 +342,8 @@
--session-status-compacting-bg: rgba(192, 132, 252, 0.28);
--session-status-idle-fg: #4ade80;
--session-status-idle-bg: rgba(74, 222, 128, 0.22);
--session-status-permission-fg: #fbbf24;
--session-status-permission-bg: rgba(251, 191, 36, 0.35);
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
--attachment-chip-bg: rgba(0, 128, 255, 0.1);

View File

@@ -5,7 +5,8 @@ import type {
EventMessagePartUpdated as MessagePartUpdatedEvent,
EventMessagePartRemoved as MessagePartRemovedEvent,
Part as SDKPart,
Message as SDKMessage
Message as SDKMessage,
Permission,
} from "@opencode-ai/sdk"
// Re-export for other modules
@@ -25,6 +26,11 @@ export interface RenderCache {
mode?: string
}
export interface PendingPermissionState {
permission: Permission
active: boolean
}
// Client-specific part extensions (using intersection type since SDKPart is a union)
export type ClientPart = SDKPart & {
sessionID?: string
@@ -32,6 +38,7 @@ export type ClientPart = SDKPart & {
synthetic?: boolean
version?: number
renderCache?: RenderCache
pendingPermission?: PendingPermissionState
}
export interface MessageDisplayParts {

View File

@@ -28,6 +28,7 @@ export interface Session extends Omit<import("@opencode-ai/sdk").Session, 'proje
messages: Message[] // Client-specific field
messagesInfo: Map<string, MessageInfo> // Client-specific field
version: string // Include version from SDK Session
pendingPermission?: boolean // Indicates if session is waiting on user permission
}
// Adapter function to convert SDK Session to client Session