feat(ui): support question tool requests
Add question queue hydration, inline answering UI, and unify pending requests with permissions.
This commit is contained in:
@@ -1,7 +1,15 @@
|
||||
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 { activePermissionId, getPermissionQueue } from "../stores/instances"
|
||||
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
|
||||
import {
|
||||
activeInterruption,
|
||||
getPermissionQueue,
|
||||
getQuestionQueue,
|
||||
getQuestionEnqueuedAtForInstance,
|
||||
setActivePermissionIdForInstance,
|
||||
setActiveQuestionIdForInstance,
|
||||
} from "../stores/instances"
|
||||
import { loadMessages, setActiveSession } from "../stores/sessions"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import ToolCall from "./tool-call"
|
||||
@@ -88,24 +96,72 @@ function resolveToolCallFromPermission(
|
||||
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 queue = createMemo(() => getPermissionQueue(props.instanceId))
|
||||
const activePermId = createMemo(() => activePermissionId().get(props.instanceId) ?? null)
|
||||
const permissionQueue = createMemo(() => getPermissionQueue(props.instanceId))
|
||||
const questionQueue = createMemo(() => getQuestionQueue(props.instanceId))
|
||||
const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null)
|
||||
|
||||
const orderedQueue = createMemo(() => {
|
||||
const current = queue()
|
||||
const activeId = activePermId()
|
||||
if (!activeId) return current
|
||||
const index = current.findIndex((entry) => entry.id === activeId)
|
||||
if (index <= 0) return current
|
||||
const active = current[index]
|
||||
if (!active) return current
|
||||
return [active, ...current.slice(0, index), ...current.slice(index + 1)]
|
||||
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 hasPermissions = createMemo(() => queue().length > 0)
|
||||
const hasRequests = createMemo(() => orderedQueue().length > 0)
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
@@ -122,7 +178,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.isOpen) return
|
||||
if (queue().length === 0) {
|
||||
if (orderedQueue().length === 0) {
|
||||
props.onClose()
|
||||
}
|
||||
})
|
||||
@@ -156,10 +212,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
<div class="permission-center-modal-header">
|
||||
<div class="permission-center-modal-title-row">
|
||||
<h2 id="permission-center-title" class="permission-center-modal-title">
|
||||
Permissions
|
||||
Requests
|
||||
</h2>
|
||||
<Show when={queue().length > 0}>
|
||||
<span class="permission-center-modal-count">{queue().length}</span>
|
||||
<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">
|
||||
@@ -168,24 +224,58 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
</div>
|
||||
|
||||
<div class="permission-center-modal-body">
|
||||
<Show when={hasPermissions()} fallback={<div class="permission-center-empty">No pending permissions.</div>}>
|
||||
<Show when={hasRequests()} fallback={<div class="permission-center-empty">No pending requests.</div>}>
|
||||
<div class="permission-center-list" role="list">
|
||||
<For each={orderedQueue()}>
|
||||
{(permission) => {
|
||||
const sessionId = getPermissionSessionId(permission) || ""
|
||||
const isActive = () => permission.id === activePermId()
|
||||
const resolved = createMemo(() => resolveToolCallFromPermission(props.instanceId, permission))
|
||||
{(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-kind">{getPermissionKind(permission)}</span>
|
||||
<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>
|
||||
@@ -195,7 +285,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
<button
|
||||
type="button"
|
||||
class="permission-center-item-action"
|
||||
onClick={() => handleGoToSession(sessionId)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleGoToSession(sessionId())
|
||||
}}
|
||||
>
|
||||
Go to Session
|
||||
</button>
|
||||
@@ -203,10 +296,13 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
<button
|
||||
type="button"
|
||||
class="permission-center-item-action"
|
||||
disabled={loadingSession() === sessionId}
|
||||
onClick={() => handleLoadSession(sessionId)}
|
||||
disabled={loadingSession() === sessionId()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleLoadSession(sessionId())
|
||||
}}
|
||||
>
|
||||
{loadingSession() === sessionId ? "Loading…" : "Load Session"}
|
||||
{loadingSession() === sessionId() ? "Loading…" : "Load Session"}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -217,7 +313,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
fallback={
|
||||
<div class="permission-center-fallback">
|
||||
<div class="permission-center-fallback-title">
|
||||
<code>{getPermissionDisplayTitle(permission)}</code>
|
||||
<code>{primaryTitle()}</code>
|
||||
</div>
|
||||
<div class="permission-center-fallback-hint">Load session for more information.</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user