Files
CodeNomad/packages/ui/src/components/permission-approval-modal.tsx
2026-01-15 08:26:49 +00:00

355 lines
13 KiB
TypeScript

import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js"
import type { PermissionRequestLike } from "../types/permission"
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
import {
activeInterruption,
getPermissionQueue,
getQuestionQueue,
getQuestionEnqueuedAtForInstance,
setActivePermissionIdForInstance,
setActiveQuestionIdForInstance,
} from "../stores/instances"
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus"
import ToolCall from "./tool-call"
interface PermissionApprovalModalProps {
instanceId: string
isOpen: boolean
onClose: () => void
}
type ResolvedToolCall = {
messageId: string
sessionId: string
toolPart: Extract<import("../types/message").ClientPart, { type: "tool" }>
messageVersion: number
partVersion: number
}
function resolveToolCallFromPermission(
instanceId: string,
permission: PermissionRequestLike,
): ResolvedToolCall | null {
const sessionId = getPermissionSessionId(permission)
const messageId = getPermissionMessageId(permission)
if (!sessionId || !messageId) return null
const store = messageStoreBus.getInstance(instanceId)
if (!store) return null
const record = store.getMessage(messageId)
if (!record) return null
const metadata = ((permission as any).metadata || {}) as Record<string, unknown>
const directPartId =
(permission as any).partID ??
(permission as any).partId ??
(metadata as any).partID ??
(metadata as any).partId ??
undefined
const callId = getPermissionCallId(permission)
const findToolPart = (partId: string) => {
const partRecord = record.parts?.[partId]
const part = partRecord?.data
if (!part || part.type !== "tool") return null
return {
toolPart: part as ResolvedToolCall["toolPart"],
partVersion: partRecord.revision ?? 0,
}
}
if (typeof directPartId === "string" && directPartId.length > 0) {
const resolved = findToolPart(directPartId)
if (resolved) {
return {
messageId,
sessionId,
toolPart: resolved.toolPart,
messageVersion: record.revision,
partVersion: resolved.partVersion,
}
}
}
if (callId) {
for (const partId of record.partIds) {
const partRecord = record.parts?.[partId]
const part = partRecord?.data as any
if (!part || part.type !== "tool") continue
const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId ?? undefined
if (partCallId === callId && typeof part.id === "string" && part.id.length > 0) {
return {
messageId,
sessionId,
toolPart: part as ResolvedToolCall["toolPart"],
messageVersion: record.revision,
partVersion: partRecord.revision ?? 0,
}
}
}
}
return null
}
function resolveToolCallFromQuestion(instanceId: string, request: QuestionRequest): ResolvedToolCall | null {
const sessionId = getQuestionSessionId(request)
const messageId = getQuestionMessageId(request)
if (!sessionId || !messageId) return null
const store = messageStoreBus.getInstance(instanceId)
if (!store) return null
const record = store.getMessage(messageId)
if (!record) return null
const callId = getQuestionCallId(request)
if (!callId) return null
for (const partId of record.partIds) {
const partRecord = record.parts?.[partId]
const part = partRecord?.data as any
if (!part || part.type !== "tool") continue
const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId ?? undefined
if (partCallId !== callId) continue
if (typeof part.id !== "string" || part.id.length === 0) continue
return {
messageId,
sessionId,
toolPart: part as ResolvedToolCall["toolPart"],
messageVersion: record.revision,
partVersion: partRecord?.revision ?? 0,
}
}
return null
}
const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => {
const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
const permissionQueue = createMemo(() => getPermissionQueue(props.instanceId))
const questionQueue = createMemo(() => getQuestionQueue(props.instanceId))
const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null)
type InterruptionItem =
| { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequestLike }
| { kind: "question"; id: string; sessionId: string; createdAt: number; payload: QuestionRequest }
const orderedQueue = createMemo<InterruptionItem[]>(() => {
const permissions = permissionQueue().map((permission) => ({
kind: "permission" as const,
id: permission.id,
sessionId: getPermissionSessionId(permission) || "",
createdAt: (permission as any)?.time?.created ?? Date.now(),
payload: permission,
}))
const questions = questionQueue().map((question) => ({
kind: "question" as const,
id: question.id,
sessionId: getQuestionSessionId(question) || "",
createdAt: getQuestionEnqueuedAtForInstance(props.instanceId, question.id),
payload: question,
}))
return [...permissions, ...questions].sort((a, b) => a.createdAt - b.createdAt)
})
const hasRequests = createMemo(() => orderedQueue().length > 0)
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault()
props.onClose()
}
}
createEffect(() => {
if (!props.isOpen) return
document.addEventListener("keydown", closeOnEscape)
onCleanup(() => document.removeEventListener("keydown", closeOnEscape))
})
createEffect(() => {
if (!props.isOpen) return
if (orderedQueue().length === 0) {
props.onClose()
}
})
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
props.onClose()
}
}
async function handleLoadSession(sessionId: string) {
if (!sessionId) return
setLoadingSession(sessionId)
try {
await loadMessages(props.instanceId, sessionId)
} finally {
setLoadingSession((current) => (current === sessionId ? null : current))
}
}
function handleGoToSession(sessionId: string) {
if (!sessionId) return
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
const parentId = session?.parentId ?? session?.id
if (parentId) {
ensureSessionParentExpanded(props.instanceId, parentId)
}
setActiveSessionFromList(props.instanceId, sessionId)
props.onClose()
}
return (
<Show when={props.isOpen}>
<div class="permission-center-modal-backdrop" onClick={handleBackdropClick}>
<div class="permission-center-modal" role="dialog" aria-modal="true" aria-labelledby="permission-center-title">
<div class="permission-center-modal-header">
<div class="permission-center-modal-title-row">
<h2 id="permission-center-title" class="permission-center-modal-title">
Requests
</h2>
<Show when={orderedQueue().length > 0}>
<span class="permission-center-modal-count">{orderedQueue().length}</span>
</Show>
</div>
<button type="button" class="permission-center-modal-close" onClick={props.onClose} aria-label="Close">
</button>
</div>
<div class="permission-center-modal-body">
<Show when={hasRequests()} fallback={<div class="permission-center-empty">No pending requests.</div>}>
<div class="permission-center-list" role="list">
<For each={orderedQueue()}>
{(item) => {
const isActive = () => active()?.kind === item.kind && active()?.id === item.id
const sessionId = () => item.sessionId
const resolved = createMemo(() => {
if (item.kind === "permission") {
return resolveToolCallFromPermission(props.instanceId, item.payload)
}
return resolveToolCallFromQuestion(props.instanceId, item.payload)
})
const showFallback = () => !resolved()
const kindLabel = () => (item.kind === "permission" ? "Permission" : "Question")
const primaryTitle = () => {
if (item.kind === "permission") {
return getPermissionDisplayTitle(item.payload)
}
const first = item.payload.questions?.[0]?.question
return typeof first === "string" && first.trim().length > 0 ? first : "Question"
}
const secondaryTitle = () => {
if (item.kind === "permission") {
return getPermissionKind(item.payload)
}
const count = item.payload.questions?.length ?? 0
return count === 1 ? "1 question" : `${count} questions`
}
const handleActivate = () => {
if (item.kind === "permission") {
setActivePermissionIdForInstance(props.instanceId, item.id)
} else {
setActiveQuestionIdForInstance(props.instanceId, item.id)
}
}
return (
<div
class={`permission-center-item${isActive() ? " permission-center-item-active" : ""}`}
role="listitem"
onClick={handleActivate}
>
<div class="permission-center-item-header">
<div class="permission-center-item-heading">
<span class={`permission-center-item-chip permission-center-item-chip-${item.kind}`}>{kindLabel()}</span>
<span class="permission-center-item-kind">{secondaryTitle()}</span>
<Show when={isActive()}>
<span class="permission-center-item-chip">Active</span>
</Show>
</div>
<div class="permission-center-item-actions">
<button
type="button"
class="permission-center-item-action"
onClick={(e) => {
e.stopPropagation()
handleGoToSession(sessionId())
}}
>
Go to Session
</button>
<Show when={showFallback()}>
<button
type="button"
class="permission-center-item-action"
disabled={loadingSession() === sessionId()}
onClick={(e) => {
e.stopPropagation()
handleLoadSession(sessionId())
}}
>
{loadingSession() === sessionId() ? "Loading…" : "Load Session"}
</button>
</Show>
</div>
</div>
<Show
when={resolved()}
fallback={
<div class="permission-center-fallback">
<div class="permission-center-fallback-title">
<code>{primaryTitle()}</code>
</div>
<div class="permission-center-fallback-hint">Load session for more information.</div>
</div>
}
>
{(data) => (
<ToolCall
toolCall={data().toolPart}
toolCallId={data().toolPart.id}
messageId={data().messageId}
messageVersion={data().messageVersion}
partVersion={data().partVersion}
instanceId={props.instanceId}
sessionId={data().sessionId}
/>
)}
</Show>
</div>
)
}}
</For>
</div>
</Show>
</div>
</div>
</div>
</Show>
)
}
export default PermissionApprovalModal