Inline permission approvals in tool calls
This commit is contained in:
@@ -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"}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user