Permissions
This commit is contained in:
72
src/App.tsx
72
src/App.tsx
@@ -3,6 +3,7 @@ import { Toaster } from "solid-toast"
|
||||
import type { Session } from "./types/session"
|
||||
import type { Attachment } from "./types/attachment"
|
||||
import type { SDKPart, ClientPart } from "./types/message"
|
||||
import type { Permission } from "@opencode-ai/sdk"
|
||||
import FolderSelectionView from "./components/folder-selection-view"
|
||||
import InstanceWelcomeView from "./components/instance-welcome-view"
|
||||
import CommandPalette from "./components/command-palette"
|
||||
@@ -36,6 +37,8 @@ import {
|
||||
stopInstance,
|
||||
getActiveInstance,
|
||||
addLog,
|
||||
getActivePermission,
|
||||
sendPermissionResponse,
|
||||
} from "./stores/instances"
|
||||
import {
|
||||
getSessions,
|
||||
@@ -173,6 +176,41 @@ const SessionView: Component<{
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle permission keyboard shortcuts
|
||||
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
|
||||
when={session()}
|
||||
@@ -194,6 +232,40 @@ const SessionView: Component<{
|
||||
onRevert={handleRevert}
|
||||
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}
|
||||
|
||||
@@ -9,7 +9,9 @@ import type {
|
||||
EventSessionUpdated,
|
||||
EventSessionCompacted,
|
||||
EventSessionError,
|
||||
EventSessionIdle
|
||||
EventSessionIdle,
|
||||
EventPermissionUpdated,
|
||||
EventPermissionReplied
|
||||
} from "@opencode-ai/sdk"
|
||||
|
||||
interface SSEConnection {
|
||||
@@ -38,6 +40,8 @@ type SSEEvent =
|
||||
| EventSessionCompacted
|
||||
| EventSessionError
|
||||
| EventSessionIdle
|
||||
| EventPermissionUpdated
|
||||
| EventPermissionReplied
|
||||
| TuiToastEvent
|
||||
| { type: string; properties?: Record<string, unknown> } // Fallback for unknown event types
|
||||
|
||||
@@ -133,6 +137,12 @@ class SSEManager {
|
||||
case "session.idle":
|
||||
this.onSessionIdle?.(instanceId, event as EventSessionIdle)
|
||||
break
|
||||
case "permission.updated":
|
||||
this.onPermissionUpdated?.(instanceId, event as EventPermissionUpdated)
|
||||
break
|
||||
case "permission.replied":
|
||||
this.onPermissionReplied?.(instanceId, event as EventPermissionReplied)
|
||||
break
|
||||
default:
|
||||
console.warn("[SSE] Unknown event type:", event.type)
|
||||
}
|
||||
@@ -176,6 +186,8 @@ class SSEManager {
|
||||
onSessionError?: (instanceId: string, event: EventSessionError) => void
|
||||
onTuiToast?: (instanceId: string, event: TuiToastEvent) => void
|
||||
onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void
|
||||
onPermissionUpdated?: (instanceId: string, event: EventPermissionUpdated) => void
|
||||
onPermissionReplied?: (instanceId: string, event: EventPermissionReplied) => void
|
||||
|
||||
getStatus(instanceId: string): "connecting" | "connected" | "disconnected" | "error" | null {
|
||||
return connectionStatus().get(instanceId) ?? null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Instance, LogEntry } from "../types/instance"
|
||||
import type { Permission } from "@opencode-ai/sdk"
|
||||
import { sdkManager } from "../lib/sdk-manager"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import {
|
||||
@@ -16,6 +17,10 @@ const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null
|
||||
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
|
||||
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
|
||||
|
||||
// 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 MAX_LOG_ENTRIES = 1000
|
||||
|
||||
function ensureLogContainer(id: string) {
|
||||
@@ -115,10 +120,11 @@ function removeInstance(id: string) {
|
||||
|
||||
if (activeInstanceId() === id) {
|
||||
if (index > 0) {
|
||||
nextActiveId = keys[index - 1]
|
||||
const prevKey = keys[index - 1]
|
||||
nextActiveId = prevKey ?? null
|
||||
} else {
|
||||
const remainingKeys = Array.from(next.keys())
|
||||
nextActiveId = remainingKeys.length > 0 ? remainingKeys[0] : null
|
||||
nextActiveId = remainingKeys.length > 0 ? (remainingKeys[0] ?? null) : null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +152,7 @@ async function createInstance(folder: string, binaryPath?: string): Promise<stri
|
||||
pid: 0,
|
||||
status: "starting",
|
||||
client: null,
|
||||
environmentVariables: preferences().environmentVariables,
|
||||
environmentVariables: preferences().environmentVariables ?? {},
|
||||
}
|
||||
|
||||
addInstance(instance)
|
||||
@@ -242,6 +248,114 @@ function clearLogs(id: string) {
|
||||
})
|
||||
}
|
||||
|
||||
// Permission management functions
|
||||
function getPermissionQueue(instanceId: string): Permission[] {
|
||||
return permissionQueues().get(instanceId) ?? []
|
||||
}
|
||||
|
||||
function getPermissionQueueLength(instanceId: string): number {
|
||||
return getPermissionQueue(instanceId).length
|
||||
}
|
||||
|
||||
function addPermissionToQueue(instanceId: string, permission: Permission): void {
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
return next
|
||||
})
|
||||
|
||||
// Set as active if no active permission
|
||||
setActivePermissionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (!next.get(instanceId)) {
|
||||
next.set(instanceId, permission.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function getActivePermission(instanceId: string): Permission | null {
|
||||
const activeId = activePermissionId().get(instanceId)
|
||||
if (!activeId) return null
|
||||
|
||||
const queue = getPermissionQueue(instanceId)
|
||||
return queue.find(p => p.id === activeId) ?? null
|
||||
}
|
||||
|
||||
function removePermissionFromQueue(instanceId: string, permissionId: string): void {
|
||||
let updatedQueue: Permission[] = []
|
||||
|
||||
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)
|
||||
} else {
|
||||
next.delete(instanceId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
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
|
||||
next.set(instanceId, nextPermission?.id ?? null)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function clearPermissionQueue(instanceId: string): void {
|
||||
setPermissionQueues((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
setActivePermissionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function sendPermissionResponse(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
permissionId: string,
|
||||
response: "once" | "always" | "reject"
|
||||
): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
await instance.client.postSessionIdPermissionsPermissionId({
|
||||
path: { id: sessionId, permissionID: permissionId },
|
||||
body: { response }
|
||||
})
|
||||
|
||||
// Remove from queue after successful response
|
||||
removePermissionFromQueue(instanceId, permissionId)
|
||||
} catch (error) {
|
||||
console.error("Failed to send permission response:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
instances,
|
||||
activeInstanceId,
|
||||
@@ -258,4 +372,14 @@ export {
|
||||
getInstanceLogs,
|
||||
isInstanceLogStreaming,
|
||||
setInstanceLogStreaming,
|
||||
// Permission management
|
||||
permissionQueues,
|
||||
activePermissionId,
|
||||
getPermissionQueue,
|
||||
getPermissionQueueLength,
|
||||
addPermissionToQueue,
|
||||
getActivePermission,
|
||||
removePermissionFromQueue,
|
||||
clearPermissionQueue,
|
||||
sendPermissionResponse,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createSignal } from "solid-js"
|
||||
import type { Session, Agent, Provider } from "../types/session"
|
||||
import type { Message, MessageDisplayParts, MessagePartRemovedEvent, MessagePartUpdatedEvent, MessageRemovedEvent, MessageUpdateEvent } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import { instances } from "./instances"
|
||||
import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances"
|
||||
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import { decodeHtmlEntities } from "../lib/markdown"
|
||||
@@ -12,7 +12,9 @@ import type {
|
||||
EventSessionUpdated,
|
||||
EventSessionCompacted,
|
||||
EventSessionError,
|
||||
EventSessionIdle
|
||||
EventSessionIdle,
|
||||
EventPermissionUpdated,
|
||||
EventPermissionReplied
|
||||
} from "@opencode-ai/sdk"
|
||||
|
||||
interface TuiToastEvent {
|
||||
@@ -2123,6 +2125,22 @@ function handleTuiToast(_instanceId: string, event: TuiToastEvent): void {
|
||||
})
|
||||
}
|
||||
|
||||
function handlePermissionUpdated(instanceId: string, event: EventPermissionUpdated): void {
|
||||
const permission = event.properties
|
||||
if (!permission) return
|
||||
|
||||
console.log(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
|
||||
addPermissionToQueue(instanceId, permission)
|
||||
}
|
||||
|
||||
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void {
|
||||
const { permissionID } = event.properties
|
||||
if (!permissionID) return
|
||||
|
||||
console.log(`[SSE] Permission replied: ${permissionID}`)
|
||||
removePermissionFromQueue(instanceId, permissionID)
|
||||
}
|
||||
|
||||
sseManager.onMessageUpdate = handleMessageUpdate
|
||||
sseManager.onMessagePartUpdated = handleMessageUpdate
|
||||
sseManager.onMessageRemoved = handleMessageRemoved
|
||||
@@ -2132,6 +2150,8 @@ sseManager.onSessionCompacted = handleSessionCompacted
|
||||
sseManager.onSessionError = handleSessionError
|
||||
sseManager.onSessionIdle = handleSessionIdle
|
||||
sseManager.onTuiToast = handleTuiToast
|
||||
sseManager.onPermissionUpdated = handlePermissionUpdated
|
||||
sseManager.onPermissionReplied = handlePermissionReplied
|
||||
|
||||
export {
|
||||
sessions,
|
||||
|
||||
@@ -46,17 +46,7 @@ export function createClientSession(
|
||||
}
|
||||
}
|
||||
|
||||
// Type guard to check if object is SDK Session
|
||||
export function isSdkSession(obj: unknown): obj is import("@opencode-ai/sdk").Session {
|
||||
return (
|
||||
typeof obj === "object" &&
|
||||
obj !== null &&
|
||||
"id" in obj &&
|
||||
"title" in obj &&
|
||||
"version" in obj &&
|
||||
"time" in obj
|
||||
)
|
||||
}
|
||||
// No type guard needed - we control the API and know the exact types we receive
|
||||
|
||||
// Our client-specific Agent interface (simplified version of SDK Agent)
|
||||
export interface Agent {
|
||||
|
||||
Reference in New Issue
Block a user