feat(ui): support question tool requests
Add question queue hydration, inline answering UI, and unify pending requests with permissions.
This commit is contained in:
8
package-lock.json
generated
8
package-lock.json
generated
@@ -1096,9 +1096,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opencode-ai/sdk": {
|
"node_modules/@opencode-ai/sdk": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz",
|
||||||
"integrity": "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ==",
|
"integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
@@ -7469,7 +7469,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@opencode-ai/sdk": "1.1.1",
|
"@opencode-ai/sdk": "1.1.11",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.1.8"
|
"@opencode-ai/plugin": "1.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@opencode-ai/sdk": "1.1.1",
|
"@opencode-ai/sdk": "1.1.11",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js"
|
import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js"
|
||||||
import type { PermissionRequestLike } from "../types/permission"
|
import type { PermissionRequestLike } from "../types/permission"
|
||||||
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } 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 { loadMessages, setActiveSession } from "../stores/sessions"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import ToolCall from "./tool-call"
|
import ToolCall from "./tool-call"
|
||||||
@@ -88,24 +96,72 @@ function resolveToolCallFromPermission(
|
|||||||
return null
|
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 PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => {
|
||||||
const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
|
const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
|
||||||
|
|
||||||
const queue = createMemo(() => getPermissionQueue(props.instanceId))
|
const permissionQueue = createMemo(() => getPermissionQueue(props.instanceId))
|
||||||
const activePermId = createMemo(() => activePermissionId().get(props.instanceId) ?? null)
|
const questionQueue = createMemo(() => getQuestionQueue(props.instanceId))
|
||||||
|
const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null)
|
||||||
|
|
||||||
const orderedQueue = createMemo(() => {
|
type InterruptionItem =
|
||||||
const current = queue()
|
| { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequestLike }
|
||||||
const activeId = activePermId()
|
| { kind: "question"; id: string; sessionId: string; createdAt: number; payload: QuestionRequest }
|
||||||
if (!activeId) return current
|
|
||||||
const index = current.findIndex((entry) => entry.id === activeId)
|
const orderedQueue = createMemo<InterruptionItem[]>(() => {
|
||||||
if (index <= 0) return current
|
const permissions = permissionQueue().map((permission) => ({
|
||||||
const active = current[index]
|
kind: "permission" as const,
|
||||||
if (!active) return current
|
id: permission.id,
|
||||||
return [active, ...current.slice(0, index), ...current.slice(index + 1)]
|
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) => {
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
@@ -122,7 +178,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!props.isOpen) return
|
if (!props.isOpen) return
|
||||||
if (queue().length === 0) {
|
if (orderedQueue().length === 0) {
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -156,10 +212,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
<div class="permission-center-modal-header">
|
<div class="permission-center-modal-header">
|
||||||
<div class="permission-center-modal-title-row">
|
<div class="permission-center-modal-title-row">
|
||||||
<h2 id="permission-center-title" class="permission-center-modal-title">
|
<h2 id="permission-center-title" class="permission-center-modal-title">
|
||||||
Permissions
|
Requests
|
||||||
</h2>
|
</h2>
|
||||||
<Show when={queue().length > 0}>
|
<Show when={orderedQueue().length > 0}>
|
||||||
<span class="permission-center-modal-count">{queue().length}</span>
|
<span class="permission-center-modal-count">{orderedQueue().length}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="permission-center-modal-close" onClick={props.onClose} aria-label="Close">
|
<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>
|
||||||
|
|
||||||
<div class="permission-center-modal-body">
|
<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">
|
<div class="permission-center-list" role="list">
|
||||||
<For each={orderedQueue()}>
|
<For each={orderedQueue()}>
|
||||||
{(permission) => {
|
{(item) => {
|
||||||
const sessionId = getPermissionSessionId(permission) || ""
|
const isActive = () => active()?.kind === item.kind && active()?.id === item.id
|
||||||
const isActive = () => permission.id === activePermId()
|
const sessionId = () => item.sessionId
|
||||||
const resolved = createMemo(() => resolveToolCallFromPermission(props.instanceId, permission))
|
|
||||||
|
const resolved = createMemo(() => {
|
||||||
|
if (item.kind === "permission") {
|
||||||
|
return resolveToolCallFromPermission(props.instanceId, item.payload)
|
||||||
|
}
|
||||||
|
return resolveToolCallFromQuestion(props.instanceId, item.payload)
|
||||||
|
})
|
||||||
|
|
||||||
const showFallback = () => !resolved()
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
class={`permission-center-item${isActive() ? " permission-center-item-active" : ""}`}
|
class={`permission-center-item${isActive() ? " permission-center-item-active" : ""}`}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
|
onClick={handleActivate}
|
||||||
>
|
>
|
||||||
<div class="permission-center-item-header">
|
<div class="permission-center-item-header">
|
||||||
<div class="permission-center-item-heading">
|
<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()}>
|
<Show when={isActive()}>
|
||||||
<span class="permission-center-item-chip">Active</span>
|
<span class="permission-center-item-chip">Active</span>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -195,7 +285,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="permission-center-item-action"
|
class="permission-center-item-action"
|
||||||
onClick={() => handleGoToSession(sessionId)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleGoToSession(sessionId())
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Go to Session
|
Go to Session
|
||||||
</button>
|
</button>
|
||||||
@@ -203,10 +296,13 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="permission-center-item-action"
|
class="permission-center-item-action"
|
||||||
disabled={loadingSession() === sessionId}
|
disabled={loadingSession() === sessionId()}
|
||||||
onClick={() => handleLoadSession(sessionId)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleLoadSession(sessionId())
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{loadingSession() === sessionId ? "Loading…" : "Load Session"}
|
{loadingSession() === sessionId() ? "Loading…" : "Load Session"}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,7 +313,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
fallback={
|
fallback={
|
||||||
<div class="permission-center-fallback">
|
<div class="permission-center-fallback">
|
||||||
<div class="permission-center-fallback-title">
|
<div class="permission-center-fallback-title">
|
||||||
<code>{getPermissionDisplayTitle(permission)}</code>
|
<code>{primaryTitle()}</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="permission-center-fallback-hint">Load session for more information.</div>
|
<div class="permission-center-fallback-hint">Load session for more information.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Show, createMemo, type Component } from "solid-js"
|
import { Show, createMemo, type Component } from "solid-js"
|
||||||
import { ShieldAlert } from "lucide-solid"
|
import { ShieldAlert } from "lucide-solid"
|
||||||
import { getPermissionQueueLength } from "../stores/instances"
|
import { getPermissionQueueLength, getQuestionQueueLength } from "../stores/instances"
|
||||||
|
|
||||||
interface PermissionNotificationBannerProps {
|
interface PermissionNotificationBannerProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
@@ -8,15 +8,21 @@ interface PermissionNotificationBannerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PermissionNotificationBanner: Component<PermissionNotificationBannerProps> = (props) => {
|
const PermissionNotificationBanner: Component<PermissionNotificationBannerProps> = (props) => {
|
||||||
const queueLength = createMemo(() => getPermissionQueueLength(props.instanceId))
|
const permissionCount = createMemo(() => getPermissionQueueLength(props.instanceId))
|
||||||
const hasPermissions = createMemo(() => queueLength() > 0)
|
const questionCount = createMemo(() => getQuestionQueueLength(props.instanceId))
|
||||||
|
const queueLength = createMemo(() => permissionCount() + questionCount())
|
||||||
|
const hasRequests = createMemo(() => queueLength() > 0)
|
||||||
const label = createMemo(() => {
|
const label = createMemo(() => {
|
||||||
const count = queueLength()
|
const total = queueLength()
|
||||||
return `${count} permission${count === 1 ? "" : "s"} pending approval`
|
const parts: string[] = []
|
||||||
|
if (permissionCount() > 0) parts.push(`${permissionCount()} permission${permissionCount() === 1 ? "" : "s"}`)
|
||||||
|
if (questionCount() > 0) parts.push(`${questionCount()} question${questionCount() === 1 ? "" : "s"}`)
|
||||||
|
const detail = parts.length ? ` (${parts.join(", ")})` : ""
|
||||||
|
return `${total} pending request${total === 1 ? "" : "s"}${detail}`
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={hasPermissions()}>
|
<Show when={hasRequests()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="permission-center-trigger"
|
class="permission-center-trigger"
|
||||||
|
|||||||
@@ -175,9 +175,11 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
const title = () => session()?.title || "Untitled"
|
const title = () => session()?.title || "Untitled"
|
||||||
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
||||||
const statusLabel = () => formatSessionStatus(status())
|
const statusLabel = () => formatSessionStatus(status())
|
||||||
const pendingPermission = () => Boolean(session()?.pendingPermission)
|
const needsPermission = () => Boolean(session()?.pendingPermission)
|
||||||
const statusClassName = () => (pendingPermission() ? "session-permission" : `session-${status()}`)
|
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
|
||||||
const statusText = () => (pendingPermission() ? "Needs Permission" : statusLabel())
|
const needsInput = () => needsPermission() || needsQuestion()
|
||||||
|
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`)
|
||||||
|
const statusText = () => (needsPermission() ? "Needs Permission" : needsQuestion() ? "Needs Input" : statusLabel())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="session-list-item group">
|
<div class="session-list-item group">
|
||||||
@@ -224,7 +226,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||||
{pendingPermission() ? (
|
{needsInput() ? (
|
||||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
) : (
|
) : (
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
if (!currentSession) return false
|
if (!currentSession) return false
|
||||||
return getSessionBusyStatus(props.instanceId, currentSession.id)
|
return getSessionBusyStatus(props.instanceId, currentSession.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const sessionNeedsInput = createMemo(() => {
|
||||||
|
const currentSession = session()
|
||||||
|
if (!currentSession) return false
|
||||||
|
return Boolean(currentSession.pendingPermission || (currentSession as any).pendingQuestion)
|
||||||
|
})
|
||||||
let scrollToBottomHandle: (() => void) | undefined
|
let scrollToBottomHandle: (() => void) | undefined
|
||||||
let rootRef: HTMLDivElement | undefined
|
let rootRef: HTMLDivElement | undefined
|
||||||
function scheduleScrollToBottom() {
|
function scheduleScrollToBottom() {
|
||||||
@@ -224,17 +230,18 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
<PromptInput
|
<PromptInput
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
instanceFolder={props.instanceFolder}
|
instanceFolder={props.instanceFolder}
|
||||||
sessionId={activeSession.id}
|
sessionId={activeSession.id}
|
||||||
onSend={handleSendMessage}
|
onSend={handleSendMessage}
|
||||||
onRunShell={handleRunShell}
|
onRunShell={handleRunShell}
|
||||||
escapeInDebounce={props.escapeInDebounce}
|
escapeInDebounce={props.escapeInDebounce}
|
||||||
isSessionBusy={sessionBusy()}
|
isSessionBusy={sessionBusy()}
|
||||||
onAbortSession={handleAbortSession}
|
disabled={sessionNeedsInput()}
|
||||||
registerQuoteHandler={registerQuoteHandler}
|
onAbortSession={handleAbortSession}
|
||||||
/>
|
registerQuoteHandler={registerQuoteHandler}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import { useTheme } from "../lib/theme"
|
|||||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import type { DiffViewMode } from "../stores/preferences"
|
import type { DiffViewMode } from "../stores/preferences"
|
||||||
import { sendPermissionResponse } from "../stores/instances"
|
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
|
||||||
import { getPermissionDisplayTitle, getPermissionKind, getPermissionSessionId } from "../types/permission"
|
import { getPermissionDisplayTitle, getPermissionKind, getPermissionSessionId } from "../types/permission"
|
||||||
|
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
import type { TextPart, RenderCache } from "../types/message"
|
import type { TextPart, RenderCache } from "../types/message"
|
||||||
import { resolveToolRenderer } from "./tool-call/renderers"
|
import { resolveToolRenderer } from "./tool-call/renderers"
|
||||||
import type {
|
import type {
|
||||||
@@ -239,6 +240,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||||
|
const activeRequest = createMemo(() => activeInterruption().get(props.instanceId) ?? null)
|
||||||
|
|
||||||
const cacheVersion = createMemo(() => {
|
const cacheVersion = createMemo(() => {
|
||||||
if (typeof props.partVersion === "number") {
|
if (typeof props.partVersion === "number") {
|
||||||
@@ -278,6 +280,16 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
}
|
}
|
||||||
return toolCallMemo()?.pendingPermission
|
return toolCallMemo()?.pendingPermission
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const questionState = createMemo(() => store().getQuestionState(props.messageId, toolCallIdentifier()))
|
||||||
|
const pendingQuestion = createMemo(() => {
|
||||||
|
const state = questionState()
|
||||||
|
if (state) {
|
||||||
|
return { request: state.entry.request as QuestionRequest, active: state.active }
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
|
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
|
||||||
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
||||||
|
|
||||||
@@ -292,27 +304,45 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
|
|
||||||
const [userExpanded, setUserExpanded] = createSignal<boolean | null>(null)
|
const [userExpanded, setUserExpanded] = createSignal<boolean | null>(null)
|
||||||
|
|
||||||
|
const isPermissionActive = createMemo(() => {
|
||||||
|
const pending = pendingPermission()
|
||||||
|
if (!pending?.permission) return false
|
||||||
|
const active = activeRequest()
|
||||||
|
return active?.kind === "permission" && active.id === pending.permission.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const isQuestionActive = createMemo(() => {
|
||||||
|
const pending = pendingQuestion()
|
||||||
|
if (!pending?.request) return false
|
||||||
|
const active = activeRequest()
|
||||||
|
return active?.kind === "question" && active.id === pending.request.id
|
||||||
|
})
|
||||||
|
|
||||||
const expanded = () => {
|
const expanded = () => {
|
||||||
const permission = pendingPermission()
|
if (isPermissionActive() || isQuestionActive()) return true
|
||||||
if (permission?.active) return true
|
|
||||||
const override = userExpanded()
|
const override = userExpanded()
|
||||||
if (override !== null) return override
|
if (override !== null) return override
|
||||||
return defaultExpandedForTool()
|
return defaultExpandedForTool()
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissionDetails = createMemo(() => pendingPermission()?.permission)
|
const permissionDetails = createMemo(() => pendingPermission()?.permission)
|
||||||
const isPermissionActive = createMemo(() => pendingPermission()?.active === true)
|
const questionDetails = createMemo(() => pendingQuestion()?.request)
|
||||||
|
|
||||||
const activePermissionKey = createMemo(() => {
|
const activePermissionKey = createMemo(() => {
|
||||||
const permission = permissionDetails()
|
const permission = permissionDetails()
|
||||||
return permission && isPermissionActive() ? permission.id : ""
|
return permission && isPermissionActive() ? permission.id : ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const activeQuestionKey = createMemo(() => {
|
||||||
|
const request = questionDetails()
|
||||||
|
return request && isQuestionActive() ? request.id : ""
|
||||||
|
})
|
||||||
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
||||||
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
||||||
const [diagnosticsOverride, setDiagnosticsOverride] = createSignal<boolean | undefined>(undefined)
|
const [diagnosticsOverride, setDiagnosticsOverride] = createSignal<boolean | undefined>(undefined)
|
||||||
|
|
||||||
const diagnosticsExpanded = () => {
|
const diagnosticsExpanded = () => {
|
||||||
const permission = pendingPermission()
|
if (isPermissionActive() || isQuestionActive()) return true
|
||||||
if (permission?.active) return true
|
|
||||||
const override = diagnosticsOverride()
|
const override = diagnosticsOverride()
|
||||||
if (override !== undefined) return override
|
if (override !== undefined) return override
|
||||||
return diagnosticsDefaultExpanded()
|
return diagnosticsDefaultExpanded()
|
||||||
@@ -513,7 +543,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const activeKey = activePermissionKey()
|
const activeKey = activePermissionKey() || activeQuestionKey()
|
||||||
if (!activeKey) return
|
if (!activeKey) return
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
toolCallRootRef?.scrollIntoView({ block: "center", behavior: "smooth" })
|
toolCallRootRef?.scrollIntoView({ block: "center", behavior: "smooth" })
|
||||||
@@ -539,6 +569,81 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
onCleanup(() => document.removeEventListener("keydown", handler))
|
onCleanup(() => document.removeEventListener("keydown", handler))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [questionSubmitting, setQuestionSubmitting] = createSignal(false)
|
||||||
|
const [questionError, setQuestionError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const [questionDraftAnswers, setQuestionDraftAnswers] = createSignal<Record<string, string[][]>>({})
|
||||||
|
const [questionCustomDraft, setQuestionCustomDraft] = createSignal<Record<string, string[]>>({})
|
||||||
|
|
||||||
|
function isTextInputFocused() {
|
||||||
|
const active = document.activeElement
|
||||||
|
return (
|
||||||
|
active?.tagName === "TEXTAREA" ||
|
||||||
|
active?.tagName === "INPUT" ||
|
||||||
|
(active?.hasAttribute("contenteditable") ?? false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleQuestionSubmit() {
|
||||||
|
const request = questionDetails()
|
||||||
|
if (!request || !isQuestionActive()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const answers = (questionDraftAnswers()[request.id] ?? []).map((x) => (Array.isArray(x) ? x : []))
|
||||||
|
const normalized = request.questions.map((_, index) => answers[index] ?? [])
|
||||||
|
if (normalized.some((item) => (item?.length ?? 0) === 0)) {
|
||||||
|
setQuestionError("Please answer all questions before submitting.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuestionSubmitting(true)
|
||||||
|
setQuestionError(null)
|
||||||
|
try {
|
||||||
|
const sessionId = (request as any).sessionID ?? (request as any).sessionId ?? props.sessionId
|
||||||
|
await sendQuestionReply(props.instanceId, sessionId, request.id, normalized)
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to send question reply", error)
|
||||||
|
setQuestionError(error instanceof Error ? error.message : "Unable to reply")
|
||||||
|
} finally {
|
||||||
|
setQuestionSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleQuestionDismiss() {
|
||||||
|
const request = questionDetails()
|
||||||
|
if (!request || !isQuestionActive()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setQuestionSubmitting(true)
|
||||||
|
setQuestionError(null)
|
||||||
|
try {
|
||||||
|
const sessionId = (request as any).sessionID ?? (request as any).sessionId ?? props.sessionId
|
||||||
|
await sendQuestionReject(props.instanceId, sessionId, request.id)
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to reject question", error)
|
||||||
|
setQuestionError(error instanceof Error ? error.message : "Unable to dismiss")
|
||||||
|
} finally {
|
||||||
|
setQuestionSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const activeKey = activeQuestionKey()
|
||||||
|
if (!activeKey) return
|
||||||
|
const handler = (event: KeyboardEvent) => {
|
||||||
|
if (isTextInputFocused()) return
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
void handleQuestionSubmit()
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
void handleQuestionDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handler)
|
||||||
|
onCleanup(() => document.removeEventListener("keydown", handler))
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
const statusIcon = () => {
|
const statusIcon = () => {
|
||||||
const status = toolState()?.status || ""
|
const status = toolState()?.status || ""
|
||||||
@@ -563,7 +668,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
|
|
||||||
const combinedStatusClass = () => {
|
const combinedStatusClass = () => {
|
||||||
const base = statusClass()
|
const base = statusClass()
|
||||||
return pendingPermission() ? `${base} tool-call-awaiting-permission` : base
|
return pendingPermission() || pendingQuestion() ? `${base} tool-call-awaiting-permission` : base
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
@@ -950,6 +1055,218 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderQuestionBlock = () => {
|
||||||
|
const state = toolState()
|
||||||
|
const request = questionDetails()
|
||||||
|
const isQuestionTool = toolName() === "question"
|
||||||
|
|
||||||
|
if (!request && !isQuestionTool) return null
|
||||||
|
|
||||||
|
const questionsSource = request?.questions ?? ((state as any)?.input?.questions as any[] | undefined) ?? []
|
||||||
|
const questions = Array.isArray(questionsSource) ? questionsSource : []
|
||||||
|
if (questions.length === 0) return null
|
||||||
|
|
||||||
|
const requestId = request?.id ?? (state as any)?.input?.requestID ?? `question-${toolCallMemo()?.id ?? "unknown"}`
|
||||||
|
const active = Boolean(request && isQuestionActive())
|
||||||
|
|
||||||
|
const completedAnswers = Array.isArray((state as any)?.metadata?.answers) ? ((state as any).metadata.answers as string[][]) : undefined
|
||||||
|
const answers = completedAnswers ?? questionDraftAnswers()[requestId] ?? []
|
||||||
|
const customInputs = questionCustomDraft()[requestId] ?? []
|
||||||
|
|
||||||
|
const updateAnswer = (questionIndex: number, next: string[]) => {
|
||||||
|
if (!active) return
|
||||||
|
setQuestionDraftAnswers((prev) => {
|
||||||
|
const current = prev[requestId] ?? []
|
||||||
|
const updated = [...current]
|
||||||
|
updated[questionIndex] = next
|
||||||
|
return { ...prev, [requestId]: updated }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCustom = (questionIndex: number, value: string) => {
|
||||||
|
if (!active) return
|
||||||
|
setQuestionCustomDraft((prev) => {
|
||||||
|
const current = prev[requestId] ?? []
|
||||||
|
const updated = [...current]
|
||||||
|
updated[questionIndex] = value
|
||||||
|
return { ...prev, [requestId]: updated }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleOption = (questionIndex: number, label: string) => {
|
||||||
|
const info = questions[questionIndex]
|
||||||
|
const multi = info?.multiple === true
|
||||||
|
const existing = answers[questionIndex] ?? []
|
||||||
|
if (multi) {
|
||||||
|
const next = existing.includes(label) ? existing.filter((x) => x !== label) : [...existing, label]
|
||||||
|
updateAnswer(questionIndex, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateAnswer(questionIndex, [label])
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitDisabled = () => {
|
||||||
|
if (!active) return true
|
||||||
|
if (questionSubmitting()) return true
|
||||||
|
return questions.some((_, index) => (answers[index]?.length ?? 0) === 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showButtons = () => active
|
||||||
|
|
||||||
|
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 ? "Question Required" : request ? "Question Queued" : "Questions"}
|
||||||
|
</span>
|
||||||
|
<span class="tool-call-permission-type">{questions.length === 1 ? "Question" : "Questions"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-call-permission-body">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<For each={questions}>
|
||||||
|
{(q, index) => {
|
||||||
|
const i = () => index()
|
||||||
|
const multi = () => q?.multiple === true
|
||||||
|
const selected = () => answers[i()] ?? []
|
||||||
|
const customValue = () => customInputs[i()] ?? ""
|
||||||
|
const inputType = () => (multi() ? "checkbox" : "radio")
|
||||||
|
const groupName = () => `question-${requestId}-${i()}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="rounded-md border border-base/60 bg-surface/30 p-3">
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<div class="text-xs">
|
||||||
|
Q{i() + 1}: <span class="font-semibold">{q?.header}</span>
|
||||||
|
</div>
|
||||||
|
<Show when={multi()}>
|
||||||
|
<div class="text-xs text-muted">Multiple</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 text-sm font-medium">{q?.question}</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-col gap-1">
|
||||||
|
<For each={q?.options ?? []}>
|
||||||
|
{(opt) => {
|
||||||
|
const checked = () => selected().includes(opt.label)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
class={`flex items-start gap-2 py-1 ${active ? "cursor-pointer" : request ? "opacity-80" : ""}`}
|
||||||
|
title={opt.description}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type={inputType()}
|
||||||
|
name={groupName()}
|
||||||
|
checked={checked()}
|
||||||
|
disabled={!active || questionSubmitting()}
|
||||||
|
onChange={() => toggleOption(i(), opt.label)}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="text-sm leading-tight">{opt.label}</div>
|
||||||
|
<div class="text-xs text-muted leading-tight">{opt.description}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<Show when={active}>
|
||||||
|
<div class="mt-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
class="flex-1 rounded-md border border-base/50 bg-surface px-2 py-1 text-sm"
|
||||||
|
type="text"
|
||||||
|
placeholder="Type your own answer"
|
||||||
|
value={customValue()}
|
||||||
|
disabled={!active || questionSubmitting()}
|
||||||
|
onInput={(e) => updateCustom(i(), e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={!active || questionSubmitting() || !customValue().trim()}
|
||||||
|
onClick={() => {
|
||||||
|
const value = customValue().trim()
|
||||||
|
if (!value) return
|
||||||
|
updateCustom(i(), value)
|
||||||
|
toggleOption(i(), value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{multi() ? "Toggle" : "Select"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<Show when={showButtons()}>
|
||||||
|
<div class="tool-call-permission-actions">
|
||||||
|
<div class="tool-call-permission-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={submitDisabled()}
|
||||||
|
onClick={() => handleQuestionSubmit()}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={questionSubmitting()}
|
||||||
|
onClick={() => handleQuestionDismiss()}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-call-permission-shortcuts">
|
||||||
|
<kbd class="kbd">Enter</kbd>
|
||||||
|
<span>Submit</span>
|
||||||
|
<kbd class="kbd">Esc</kbd>
|
||||||
|
<span>Dismiss</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={questionError()}>
|
||||||
|
<div class="tool-call-permission-error">{questionError()}</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!active && request}>
|
||||||
|
<p class="tool-call-permission-queued-text">Waiting for earlier responses.</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const request = questionDetails()
|
||||||
|
if (!request) {
|
||||||
|
setQuestionSubmitting(false)
|
||||||
|
setQuestionError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setQuestionError(null)
|
||||||
|
const requestId = request.id
|
||||||
|
setQuestionDraftAnswers((prev) => {
|
||||||
|
if (prev[requestId]) return prev
|
||||||
|
const initial = request.questions.map(() => [])
|
||||||
|
return { ...prev, [requestId]: initial }
|
||||||
|
})
|
||||||
|
setQuestionCustomDraft((prev) => {
|
||||||
|
if (prev[requestId]) return prev
|
||||||
|
const initial = request.questions.map(() => "")
|
||||||
|
return { ...prev, [requestId]: initial }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const status = () => toolState()?.status || ""
|
const status = () => toolState()?.status || ""
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -993,6 +1310,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
{renderError()}
|
{renderError()}
|
||||||
|
|
||||||
{renderPermissionBlock()}
|
{renderPermissionBlock()}
|
||||||
|
{renderQuestionBlock()}
|
||||||
|
|
||||||
<Show when={status() === "pending" && !pendingPermission()}>
|
<Show when={status() === "pending" && !pendingPermission()}>
|
||||||
<div class="tool-call-pending-message">
|
<div class="tool-call-pending-message">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { todoRenderer } from "./todo"
|
|||||||
import { webfetchRenderer } from "./webfetch"
|
import { webfetchRenderer } from "./webfetch"
|
||||||
import { writeRenderer } from "./write"
|
import { writeRenderer } from "./write"
|
||||||
import { invalidRenderer } from "./invalid"
|
import { invalidRenderer } from "./invalid"
|
||||||
|
import { questionRenderer } from "./question"
|
||||||
|
|
||||||
const TOOL_RENDERERS: ToolRenderer[] = [
|
const TOOL_RENDERERS: ToolRenderer[] = [
|
||||||
bashRenderer,
|
bashRenderer,
|
||||||
@@ -19,6 +20,7 @@ const TOOL_RENDERERS: ToolRenderer[] = [
|
|||||||
webfetchRenderer,
|
webfetchRenderer,
|
||||||
todoRenderer,
|
todoRenderer,
|
||||||
taskRenderer,
|
taskRenderer,
|
||||||
|
questionRenderer,
|
||||||
invalidRenderer,
|
invalidRenderer,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
17
packages/ui/src/components/tool-call/renderers/question.tsx
Normal file
17
packages/ui/src/components/tool-call/renderers/question.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { ToolRenderer } from "../types"
|
||||||
|
|
||||||
|
export const questionRenderer: ToolRenderer = {
|
||||||
|
tools: ["question"],
|
||||||
|
getAction: () => "Awaiting answers...",
|
||||||
|
getTitle({ toolState }) {
|
||||||
|
const state = toolState()
|
||||||
|
if (!state) return "Questions"
|
||||||
|
if (state.status === "completed") return "Questions"
|
||||||
|
return "Asking questions"
|
||||||
|
},
|
||||||
|
renderBody() {
|
||||||
|
// The question tool UI is rendered by ToolCall itself so
|
||||||
|
// it can share the same layout for pending/completed.
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -45,6 +45,8 @@ export function getToolIcon(tool: string): string {
|
|||||||
case "todowrite":
|
case "todowrite":
|
||||||
case "todoread":
|
case "todoread":
|
||||||
return "📋"
|
return "📋"
|
||||||
|
case "question":
|
||||||
|
return "❓"
|
||||||
case "list":
|
case "list":
|
||||||
return "📁"
|
return "📁"
|
||||||
case "patch":
|
case "patch":
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ type SSEEvent =
|
|||||||
| EventSessionIdle
|
| EventSessionIdle
|
||||||
| { type: "permission.updated" | "permission.asked"; properties?: any }
|
| { type: "permission.updated" | "permission.asked"; properties?: any }
|
||||||
| { type: "permission.replied"; properties?: any }
|
| { type: "permission.replied"; properties?: any }
|
||||||
|
| { type: "question.asked"; properties?: any }
|
||||||
|
| { type: "question.replied" | "question.rejected"; properties?: any }
|
||||||
| EventLspUpdated
|
| EventLspUpdated
|
||||||
| TuiToastEvent
|
| TuiToastEvent
|
||||||
| BackgroundProcessUpdatedEvent
|
| BackgroundProcessUpdatedEvent
|
||||||
@@ -144,6 +146,13 @@ class SSEManager {
|
|||||||
case "permission.replied":
|
case "permission.replied":
|
||||||
this.onPermissionReplied?.(instanceId, event as any)
|
this.onPermissionReplied?.(instanceId, event as any)
|
||||||
break
|
break
|
||||||
|
case "question.asked":
|
||||||
|
this.onQuestionAsked?.(instanceId, event as any)
|
||||||
|
break
|
||||||
|
case "question.replied":
|
||||||
|
case "question.rejected":
|
||||||
|
this.onQuestionAnswered?.(instanceId, event as any)
|
||||||
|
break
|
||||||
case "lsp.updated":
|
case "lsp.updated":
|
||||||
this.onLspUpdated?.(instanceId, event as EventLspUpdated)
|
this.onLspUpdated?.(instanceId, event as EventLspUpdated)
|
||||||
break
|
break
|
||||||
@@ -178,6 +187,8 @@ class SSEManager {
|
|||||||
onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void
|
onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void
|
||||||
onPermissionUpdated?: (instanceId: string, event: any) => void
|
onPermissionUpdated?: (instanceId: string, event: any) => void
|
||||||
onPermissionReplied?: (instanceId: string, event: any) => void
|
onPermissionReplied?: (instanceId: string, event: any) => void
|
||||||
|
onQuestionAsked?: (instanceId: string, event: any) => void
|
||||||
|
onQuestionAnswered?: (instanceId: string, event: any) => void
|
||||||
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
|
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
|
||||||
onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void
|
onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void
|
||||||
onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void
|
onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { Instance, LogEntry } from "../types/instance"
|
|||||||
import type { LspStatus } from "@opencode-ai/sdk/v2"
|
import type { LspStatus } from "@opencode-ai/sdk/v2"
|
||||||
import type { PermissionReply, PermissionRequestLike } from "../types/permission"
|
import type { PermissionReply, PermissionRequestLike } from "../types/permission"
|
||||||
import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permission"
|
import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permission"
|
||||||
|
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
|
import { getQuestionSessionId } from "../types/question"
|
||||||
import { requestData } from "../lib/opencode-api"
|
import { requestData } from "../lib/opencode-api"
|
||||||
import { sdkManager } from "../lib/sdk-manager"
|
import { sdkManager } from "../lib/sdk-manager"
|
||||||
import { sseManager } from "../lib/sse-manager"
|
import { sseManager } from "../lib/sse-manager"
|
||||||
@@ -18,10 +20,10 @@ import {
|
|||||||
} from "./sessions"
|
} from "./sessions"
|
||||||
import { fetchCommands, clearCommands } from "./commands"
|
import { fetchCommands, clearCommands } from "./commands"
|
||||||
import { preferences } from "./preferences"
|
import { preferences } from "./preferences"
|
||||||
import { setSessionPendingPermission } from "./session-state"
|
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
||||||
import { setHasInstances } from "./ui"
|
import { setHasInstances } from "./ui"
|
||||||
import { messageStoreBus } from "./message-v2/bus"
|
import { messageStoreBus } from "./message-v2/bus"
|
||||||
import { upsertPermissionV2, removePermissionV2 } from "./message-v2/bridge"
|
import { upsertPermissionV2, removePermissionV2, upsertQuestionV2, removeQuestionV2 } from "./message-v2/bridge"
|
||||||
import { clearCacheForInstance } from "../lib/global-cache"
|
import { clearCacheForInstance } from "../lib/global-cache"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
|
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
|
||||||
@@ -34,11 +36,30 @@ const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null
|
|||||||
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
|
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
|
||||||
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
|
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
|
||||||
|
|
||||||
// Permission queue management per instance
|
// Interruption queues (permissions + questions) per instance
|
||||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequestLike[]>>(new Map())
|
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequestLike[]>>(new Map())
|
||||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||||
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
||||||
|
|
||||||
|
const [questionQueues, setQuestionQueues] = createSignal<Map<string, QuestionRequest[]>>(new Map())
|
||||||
|
const [activeQuestionId, setActiveQuestionId] = createSignal<Map<string, string | null>>(new Map())
|
||||||
|
const questionSessionCounts = new Map<string, Map<string, number>>()
|
||||||
|
const questionEnqueuedAt = new Map<string, number>()
|
||||||
|
|
||||||
|
function ensureQuestionEnqueuedAt(request: QuestionRequest): number {
|
||||||
|
const existing = questionEnqueuedAt.get(request.id)
|
||||||
|
if (existing) return existing
|
||||||
|
const now = Date.now()
|
||||||
|
questionEnqueuedAt.set(request.id, now)
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
|
type InterruptionKind = "permission" | "question"
|
||||||
|
|
||||||
|
type ActiveInterruption = { kind: InterruptionKind; id: string } | null
|
||||||
|
|
||||||
|
const [activeInterruption, setActiveInterruption] = createSignal<Map<string, ActiveInterruption>>(new Map())
|
||||||
|
|
||||||
function syncHasInstancesFlag() {
|
function syncHasInstancesFlag() {
|
||||||
const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready")
|
const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready")
|
||||||
setHasInstances(readyExists)
|
setHasInstances(readyExists)
|
||||||
@@ -156,6 +177,38 @@ async function syncPendingPermissions(instanceId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncPendingQuestions(instanceId: string): Promise<void> {
|
||||||
|
const instance = instances().get(instanceId)
|
||||||
|
if (!instance?.client) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const remote = await requestData<QuestionRequest[]>(
|
||||||
|
instance.client.question.list(),
|
||||||
|
"question.list",
|
||||||
|
)
|
||||||
|
|
||||||
|
const remoteIds = new Set(remote.map((item) => item.id))
|
||||||
|
const local = getQuestionQueue(instanceId)
|
||||||
|
|
||||||
|
// Remove any stale local requests missing from server.
|
||||||
|
for (const entry of local) {
|
||||||
|
if (!remoteIds.has(entry.id)) {
|
||||||
|
removeQuestionFromQueue(instanceId, entry.id)
|
||||||
|
removeQuestionV2(instanceId, entry.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert all server-side pending questions.
|
||||||
|
for (const request of remote) {
|
||||||
|
ensureQuestionEnqueuedAt(request)
|
||||||
|
addQuestionToQueue(instanceId, request)
|
||||||
|
upsertQuestionV2(instanceId, request)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("Failed to sync pending questions", { instanceId, error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function hydrateInstanceData(instanceId: string) {
|
async function hydrateInstanceData(instanceId: string) {
|
||||||
try {
|
try {
|
||||||
await fetchSessions(instanceId)
|
await fetchSessions(instanceId)
|
||||||
@@ -166,6 +219,7 @@ async function hydrateInstanceData(instanceId: string) {
|
|||||||
if (!instance?.client) return
|
if (!instance?.client) return
|
||||||
await fetchCommands(instanceId, instance.client)
|
await fetchCommands(instanceId, instance.client)
|
||||||
await syncPendingPermissions(instanceId)
|
await syncPendingPermissions(instanceId)
|
||||||
|
await syncPendingQuestions(instanceId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to fetch initial data", error)
|
log.error("Failed to fetch initial data", error)
|
||||||
}
|
}
|
||||||
@@ -327,6 +381,7 @@ function removeInstance(id: string) {
|
|||||||
removeLogContainer(id)
|
removeLogContainer(id)
|
||||||
clearCommands(id)
|
clearCommands(id)
|
||||||
clearPermissionQueue(id)
|
clearPermissionQueue(id)
|
||||||
|
clearQuestionQueue(id)
|
||||||
clearInstanceMetadata(id)
|
clearInstanceMetadata(id)
|
||||||
|
|
||||||
if (activeInstanceId() === id) {
|
if (activeInstanceId() === id) {
|
||||||
@@ -429,6 +484,79 @@ function getPermissionQueueLength(instanceId: string): number {
|
|||||||
return getPermissionQueue(instanceId).length
|
return getPermissionQueue(instanceId).length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getQuestionQueue(instanceId: string): QuestionRequest[] {
|
||||||
|
const queue = questionQueues().get(instanceId)
|
||||||
|
if (!queue) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return queue
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuestionQueueLength(instanceId: string): number {
|
||||||
|
return getQuestionQueue(instanceId).length
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuestionEnqueuedAtForInstance(instanceId: string, requestId: string): number {
|
||||||
|
// Ensure we have a stable timestamp for sorting/ordering.
|
||||||
|
const queue = getQuestionQueue(instanceId)
|
||||||
|
const match = queue.find((q) => q.id === requestId)
|
||||||
|
if (match) {
|
||||||
|
return ensureQuestionEnqueuedAt(match)
|
||||||
|
}
|
||||||
|
return questionEnqueuedAt.get(requestId) ?? Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeActiveInterruption(instanceId: string): ActiveInterruption {
|
||||||
|
const permissions = getPermissionQueue(instanceId)
|
||||||
|
const questions = getQuestionQueue(instanceId)
|
||||||
|
const firstPermission = permissions[0]
|
||||||
|
const firstQuestion = questions[0]
|
||||||
|
if (!firstPermission && !firstQuestion) return null
|
||||||
|
if (firstPermission && !firstQuestion) return { kind: "permission", id: firstPermission.id }
|
||||||
|
if (firstQuestion && !firstPermission) return { kind: "question", id: firstQuestion.id }
|
||||||
|
|
||||||
|
const permTime = getPermissionCreatedAt(firstPermission)
|
||||||
|
const quesTime = firstQuestion ? ensureQuestionEnqueuedAt(firstQuestion) : Number.MAX_SAFE_INTEGER
|
||||||
|
if (permTime <= quesTime) return { kind: "permission", id: firstPermission.id }
|
||||||
|
return { kind: "question", id: firstQuestion!.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveInterruptionForInstance(instanceId: string, nextActive: ActiveInterruption): void {
|
||||||
|
setActiveInterruption((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
if (!nextActive) {
|
||||||
|
next.set(instanceId, null)
|
||||||
|
} else {
|
||||||
|
next.set(instanceId, nextActive)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
setActivePermissionId((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
if (nextActive?.kind === "permission") {
|
||||||
|
next.set(instanceId, nextActive.id)
|
||||||
|
} else {
|
||||||
|
next.set(instanceId, null)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
setActiveQuestionId((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
if (nextActive?.kind === "question") {
|
||||||
|
next.set(instanceId, nextActive.id)
|
||||||
|
} else {
|
||||||
|
next.set(instanceId, null)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function recomputeActiveInterruption(instanceId: string): void {
|
||||||
|
setActiveInterruptionForInstance(instanceId, computeActiveInterruption(instanceId))
|
||||||
|
}
|
||||||
|
|
||||||
function incrementSessionPendingCount(instanceId: string, sessionId: string): void {
|
function incrementSessionPendingCount(instanceId: string, sessionId: string): void {
|
||||||
let sessionCounts = permissionSessionCounts.get(instanceId)
|
let sessionCounts = permissionSessionCounts.get(instanceId)
|
||||||
if (!sessionCounts) {
|
if (!sessionCounts) {
|
||||||
@@ -464,6 +592,41 @@ function clearSessionPendingCounts(instanceId: string): void {
|
|||||||
permissionSessionCounts.delete(instanceId)
|
permissionSessionCounts.delete(instanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function incrementQuestionSessionPendingCount(instanceId: string, sessionId: string): void {
|
||||||
|
let sessionCounts = questionSessionCounts.get(instanceId)
|
||||||
|
if (!sessionCounts) {
|
||||||
|
sessionCounts = new Map()
|
||||||
|
questionSessionCounts.set(instanceId, sessionCounts)
|
||||||
|
}
|
||||||
|
const current = sessionCounts.get(sessionId) ?? 0
|
||||||
|
sessionCounts.set(sessionId, current + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrementQuestionSessionPendingCount(instanceId: string, sessionId: string): number {
|
||||||
|
const sessionCounts = questionSessionCounts.get(instanceId)
|
||||||
|
if (!sessionCounts) return 0
|
||||||
|
const current = sessionCounts.get(sessionId) ?? 0
|
||||||
|
if (current <= 1) {
|
||||||
|
sessionCounts.delete(sessionId)
|
||||||
|
if (sessionCounts.size === 0) {
|
||||||
|
questionSessionCounts.delete(instanceId)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const nextValue = current - 1
|
||||||
|
sessionCounts.set(sessionId, nextValue)
|
||||||
|
return nextValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQuestionSessionPendingCounts(instanceId: string): void {
|
||||||
|
const sessionCounts = questionSessionCounts.get(instanceId)
|
||||||
|
if (!sessionCounts) return
|
||||||
|
for (const sessionId of sessionCounts.keys()) {
|
||||||
|
setSessionPendingQuestion(instanceId, sessionId, false)
|
||||||
|
}
|
||||||
|
questionSessionCounts.delete(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
function addPermissionToQueue(instanceId: string, permission: PermissionRequestLike): void {
|
function addPermissionToQueue(instanceId: string, permission: PermissionRequestLike): void {
|
||||||
let inserted = false
|
let inserted = false
|
||||||
|
|
||||||
@@ -485,13 +648,7 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setActivePermissionId((prev) => {
|
recomputeActiveInterruption(instanceId)
|
||||||
const next = new Map(prev)
|
|
||||||
if (!next.get(instanceId)) {
|
|
||||||
next.set(instanceId, permission.id)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
const sessionId = getPermissionSessionId(permission)
|
const sessionId = getPermissionSessionId(permission)
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
@@ -526,15 +683,7 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
|
|||||||
|
|
||||||
const updatedQueue = getPermissionQueue(instanceId)
|
const updatedQueue = getPermissionQueue(instanceId)
|
||||||
|
|
||||||
setActivePermissionId((prev) => {
|
recomputeActiveInterruption(instanceId)
|
||||||
const next = new Map(prev)
|
|
||||||
const activeId = next.get(instanceId)
|
|
||||||
if (activeId === permissionId) {
|
|
||||||
const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as PermissionRequestLike) : null
|
|
||||||
next.set(instanceId, nextPermission?.id ?? null)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
const removed = removedPermission
|
const removed = removedPermission
|
||||||
if (removed) {
|
if (removed) {
|
||||||
@@ -558,16 +707,140 @@ function clearPermissionQueue(instanceId: string): void {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
clearSessionPendingCounts(instanceId)
|
clearSessionPendingCounts(instanceId)
|
||||||
|
recomputeActiveInterruption(instanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addQuestionToQueue(instanceId: string, request: QuestionRequest): void {
|
||||||
|
let inserted = false
|
||||||
|
|
||||||
|
setQuestionQueues((prev) => {
|
||||||
function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void {
|
|
||||||
setActivePermissionId((prev) => {
|
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
next.set(instanceId, permissionId)
|
const queue = next.get(instanceId) ?? ([] as QuestionRequest[])
|
||||||
|
|
||||||
|
if (queue.some((q) => q.id === request.id)) {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureQuestionEnqueuedAt(request)
|
||||||
|
const updatedQueue = [...queue, request].sort((a, b) => {
|
||||||
|
return ensureQuestionEnqueuedAt(a) - ensureQuestionEnqueuedAt(b)
|
||||||
|
})
|
||||||
|
next.set(instanceId, updatedQueue)
|
||||||
|
inserted = true
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!inserted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recomputeActiveInterruption(instanceId)
|
||||||
|
|
||||||
|
const sessionId = getQuestionSessionId(request)
|
||||||
|
if (sessionId) {
|
||||||
|
incrementQuestionSessionPendingCount(instanceId, sessionId)
|
||||||
|
setSessionPendingQuestion(instanceId, sessionId, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeQuestionFromQueue(instanceId: string, requestId: string): void {
|
||||||
|
const removedSessionId = getQuestionSessionId(getQuestionQueue(instanceId).find((q) => q.id === requestId))
|
||||||
|
|
||||||
|
setQuestionQueues((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
const queue = next.get(instanceId) ?? ([] as QuestionRequest[])
|
||||||
|
const filtered = queue.filter((item) => item.id !== requestId)
|
||||||
|
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
next.set(instanceId, filtered)
|
||||||
|
} else {
|
||||||
|
next.delete(instanceId)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
questionEnqueuedAt.delete(requestId)
|
||||||
|
recomputeActiveInterruption(instanceId)
|
||||||
|
|
||||||
|
if (removedSessionId) {
|
||||||
|
const remaining = decrementQuestionSessionPendingCount(instanceId, removedSessionId)
|
||||||
|
setSessionPendingQuestion(instanceId, removedSessionId, remaining > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQuestionQueue(instanceId: string): void {
|
||||||
|
for (const request of getQuestionQueue(instanceId)) {
|
||||||
|
questionEnqueuedAt.delete(request.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuestionQueues((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.delete(instanceId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setActiveQuestionId((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.delete(instanceId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
clearQuestionSessionPendingCounts(instanceId)
|
||||||
|
recomputeActiveInterruption(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void {
|
||||||
|
setActiveInterruptionForInstance(instanceId, { kind: "permission", id: permissionId })
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveQuestionIdForInstance(instanceId: string, requestId: string): void {
|
||||||
|
setActiveInterruptionForInstance(instanceId, { kind: "question", id: requestId })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendQuestionReply(
|
||||||
|
instanceId: string,
|
||||||
|
_sessionId: string,
|
||||||
|
requestId: string,
|
||||||
|
answers: string[][],
|
||||||
|
): Promise<void> {
|
||||||
|
const instance = instances().get(instanceId)
|
||||||
|
if (!instance?.client) {
|
||||||
|
throw new Error("Instance not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requestData(
|
||||||
|
instance.client.question.reply({
|
||||||
|
requestID: requestId,
|
||||||
|
answers,
|
||||||
|
}),
|
||||||
|
"question.reply",
|
||||||
|
)
|
||||||
|
|
||||||
|
removeQuestionFromQueue(instanceId, requestId)
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to send question reply", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendQuestionReject(instanceId: string, _sessionId: string, requestId: string): Promise<void> {
|
||||||
|
const instance = instances().get(instanceId)
|
||||||
|
if (!instance?.client) {
|
||||||
|
throw new Error("Instance not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requestData(
|
||||||
|
instance.client.question.reject({
|
||||||
|
requestID: requestId,
|
||||||
|
}),
|
||||||
|
"question.reject",
|
||||||
|
)
|
||||||
|
|
||||||
|
removeQuestionFromQueue(instanceId, requestId)
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to send question reject", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendPermissionResponse(
|
async function sendPermissionResponse(
|
||||||
@@ -655,7 +928,7 @@ export {
|
|||||||
getInstanceLogs,
|
getInstanceLogs,
|
||||||
isInstanceLogStreaming,
|
isInstanceLogStreaming,
|
||||||
setInstanceLogStreaming,
|
setInstanceLogStreaming,
|
||||||
// Permission management
|
// Permission + question management
|
||||||
permissionQueues,
|
permissionQueues,
|
||||||
activePermissionId,
|
activePermissionId,
|
||||||
getPermissionQueue,
|
getPermissionQueue,
|
||||||
@@ -665,6 +938,18 @@ export {
|
|||||||
clearPermissionQueue,
|
clearPermissionQueue,
|
||||||
sendPermissionResponse,
|
sendPermissionResponse,
|
||||||
setActivePermissionIdForInstance,
|
setActivePermissionIdForInstance,
|
||||||
|
questionQueues,
|
||||||
|
activeQuestionId,
|
||||||
|
activeInterruption,
|
||||||
|
getQuestionQueue,
|
||||||
|
getQuestionQueueLength,
|
||||||
|
getQuestionEnqueuedAtForInstance,
|
||||||
|
addQuestionToQueue,
|
||||||
|
removeQuestionFromQueue,
|
||||||
|
clearQuestionQueue,
|
||||||
|
sendQuestionReply,
|
||||||
|
sendQuestionReject,
|
||||||
|
setActiveQuestionIdForInstance,
|
||||||
disconnectedInstance,
|
disconnectedInstance,
|
||||||
acknowledgeDisconnectedInstance,
|
acknowledgeDisconnectedInstance,
|
||||||
fetchLspStatus,
|
fetchLspStatus,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { PermissionRequestLike } from "../../types/permission"
|
import type { PermissionRequestLike } from "../../types/permission"
|
||||||
import { getPermissionCallId, getPermissionMessageId } from "../../types/permission"
|
import { getPermissionCallId, getPermissionMessageId } from "../../types/permission"
|
||||||
|
import type { QuestionRequest } from "../../types/question"
|
||||||
|
import { getQuestionCallId, getQuestionMessageId } from "../../types/question"
|
||||||
import type { Message, MessageInfo, ClientPart } from "../../types/message"
|
import type { Message, MessageInfo, ClientPart } from "../../types/message"
|
||||||
import type { Session } from "../../types/session"
|
import type { Session } from "../../types/session"
|
||||||
import { messageStoreBus } from "./bus"
|
import { messageStoreBus } from "./bus"
|
||||||
@@ -192,6 +194,65 @@ export function reconcilePendingPermissionsV2(instanceId: string, sessionId?: st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractQuestionMessageId(request: QuestionRequest): string | undefined {
|
||||||
|
return getQuestionMessageId(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractQuestionCallId(request: QuestionRequest): string | undefined {
|
||||||
|
return getQuestionCallId(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertQuestionV2(instanceId: string, request: QuestionRequest): void {
|
||||||
|
if (!request) return
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
const messageId = extractQuestionMessageId(request)
|
||||||
|
let partId: string | undefined = undefined
|
||||||
|
const callId = extractQuestionCallId(request)
|
||||||
|
if (callId) {
|
||||||
|
partId = resolvePartIdFromCallId(store, messageId, callId)
|
||||||
|
}
|
||||||
|
store.upsertQuestion({
|
||||||
|
request,
|
||||||
|
messageId,
|
||||||
|
partId,
|
||||||
|
enqueuedAt: (request as any).time?.created ?? Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcilePendingQuestionsV2(instanceId: string, sessionId?: string): void {
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
const pending = store.state.questions.queue
|
||||||
|
if (!pending || pending.length === 0) return
|
||||||
|
|
||||||
|
for (const entry of pending) {
|
||||||
|
if (!entry || entry.partId) continue
|
||||||
|
const request = entry.request
|
||||||
|
if (!request) continue
|
||||||
|
|
||||||
|
const questionSessionId = request.sessionID
|
||||||
|
if (sessionId && questionSessionId && questionSessionId !== sessionId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = entry.messageId ?? extractQuestionMessageId(request)
|
||||||
|
const callId = extractQuestionCallId(request)
|
||||||
|
const resolvedPartId = resolvePartIdFromCallId(store, messageId, callId)
|
||||||
|
if (!resolvedPartId) continue
|
||||||
|
|
||||||
|
store.upsertQuestion({
|
||||||
|
...entry,
|
||||||
|
messageId,
|
||||||
|
partId: resolvedPartId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeQuestionV2(instanceId: string, requestId: string): void {
|
||||||
|
if (!requestId) return
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
store.removeQuestion(requestId)
|
||||||
|
}
|
||||||
|
|
||||||
export function removePermissionV2(instanceId: string, permissionId: string): void {
|
export function removePermissionV2(instanceId: string, permissionId: string): void {
|
||||||
if (!permissionId) return
|
if (!permissionId) return
|
||||||
const store = messageStoreBus.getOrCreate(instanceId)
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
PartUpdateInput,
|
PartUpdateInput,
|
||||||
PendingPartEntry,
|
PendingPartEntry,
|
||||||
PermissionEntry,
|
PermissionEntry,
|
||||||
|
QuestionEntry,
|
||||||
ReplaceMessageIdOptions,
|
ReplaceMessageIdOptions,
|
||||||
ScrollSnapshot,
|
ScrollSnapshot,
|
||||||
SessionRecord,
|
SessionRecord,
|
||||||
@@ -40,6 +41,11 @@ function createInitialState(instanceId: string): InstanceMessageState {
|
|||||||
active: null,
|
active: null,
|
||||||
byMessage: {},
|
byMessage: {},
|
||||||
},
|
},
|
||||||
|
questions: {
|
||||||
|
queue: [],
|
||||||
|
active: null,
|
||||||
|
byMessage: {},
|
||||||
|
},
|
||||||
usage: {},
|
usage: {},
|
||||||
scrollState: {},
|
scrollState: {},
|
||||||
latestTodos: {},
|
latestTodos: {},
|
||||||
@@ -193,6 +199,9 @@ export interface InstanceMessageStore {
|
|||||||
upsertPermission: (entry: PermissionEntry) => void
|
upsertPermission: (entry: PermissionEntry) => void
|
||||||
removePermission: (permissionId: string) => void
|
removePermission: (permissionId: string) => void
|
||||||
getPermissionState: (messageId?: string, partId?: string) => { entry: PermissionEntry; active: boolean } | null
|
getPermissionState: (messageId?: string, partId?: string) => { entry: PermissionEntry; active: boolean } | null
|
||||||
|
upsertQuestion: (entry: QuestionEntry) => void
|
||||||
|
removeQuestion: (requestId: string) => void
|
||||||
|
getQuestionState: (messageId?: string, partId?: string) => { entry: QuestionEntry; active: boolean } | null
|
||||||
setSessionRevert: (sessionId: string, revert?: SessionRecord["revert"] | null) => void
|
setSessionRevert: (sessionId: string, revert?: SessionRecord["revert"] | null) => void
|
||||||
getSessionRevert: (sessionId: string) => SessionRecord["revert"] | undefined | null
|
getSessionRevert: (sessionId: string) => SessionRecord["revert"] | undefined | null
|
||||||
rebuildUsage: (sessionId: string, infos: Iterable<MessageInfo>) => void
|
rebuildUsage: (sessionId: string, infos: Iterable<MessageInfo>) => void
|
||||||
@@ -757,6 +766,18 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const questionMap = state.questions.byMessage[options.oldId]
|
||||||
|
if (questionMap) {
|
||||||
|
setState("questions", "byMessage", options.newId, questionMap)
|
||||||
|
setState("questions", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
const nextByMessage = { ...next.byMessage }
|
||||||
|
delete nextByMessage[options.oldId]
|
||||||
|
next.byMessage = nextByMessage
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const pending = state.pendingParts[options.oldId]
|
const pending = state.pendingParts[options.oldId]
|
||||||
if (pending) {
|
if (pending) {
|
||||||
setState("pendingParts", options.newId, pending)
|
setState("pendingParts", options.newId, pending)
|
||||||
@@ -832,6 +853,60 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
return { entry, active }
|
return { entry, active }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upsertQuestion(entry: QuestionEntry) {
|
||||||
|
const messageKey = entry.messageId ?? "__global__"
|
||||||
|
const partKey = entry.partId ?? entry.request?.id ?? "__global__"
|
||||||
|
|
||||||
|
setState(
|
||||||
|
"questions",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.byMessage[messageKey] = draft.byMessage[messageKey] ?? {}
|
||||||
|
draft.byMessage[messageKey][partKey] = entry
|
||||||
|
const existingIndex = draft.queue.findIndex((item) => item.request.id === entry.request.id)
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
draft.queue.push(entry)
|
||||||
|
} else {
|
||||||
|
draft.queue[existingIndex] = entry
|
||||||
|
}
|
||||||
|
if (!draft.active || draft.active.request.id === entry.request.id) {
|
||||||
|
draft.active = entry
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeQuestion(requestId: string) {
|
||||||
|
setState(
|
||||||
|
"questions",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.queue = draft.queue.filter((item) => item.request.id !== requestId)
|
||||||
|
if (draft.active?.request.id === requestId) {
|
||||||
|
draft.active = draft.queue[0] ?? null
|
||||||
|
}
|
||||||
|
Object.keys(draft.byMessage).forEach((messageKey) => {
|
||||||
|
const partEntries = draft.byMessage[messageKey]
|
||||||
|
Object.keys(partEntries).forEach((partKey) => {
|
||||||
|
if (partEntries[partKey].request.id === requestId) {
|
||||||
|
delete partEntries[partKey]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (Object.keys(partEntries).length === 0) {
|
||||||
|
delete draft.byMessage[messageKey]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuestionState(messageId?: string, partId?: string) {
|
||||||
|
const messageKey = messageId ?? "__global__"
|
||||||
|
const partKey = partId ?? "__global__"
|
||||||
|
const entry = state.questions.byMessage[messageKey]?.[partKey]
|
||||||
|
if (!entry) return null
|
||||||
|
const active = state.questions.active?.request.id === entry.request.id
|
||||||
|
return { entry, active }
|
||||||
|
}
|
||||||
|
|
||||||
function pruneMessagesAfterRevert(sessionId: string, revertMessageId: string) {
|
function pruneMessagesAfterRevert(sessionId: string, revertMessageId: string) {
|
||||||
const session = state.sessions[sessionId]
|
const session = state.sessions[sessionId]
|
||||||
if (!session) return
|
if (!session) return
|
||||||
@@ -873,6 +948,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setState("questions", "byMessage", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
removedIds.forEach((id) => {
|
||||||
|
if (next[id]) delete next[id]
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
withUsageState(sessionId, (draft) => {
|
withUsageState(sessionId, (draft) => {
|
||||||
removedIds.forEach((id) => removeUsageEntry(draft, id))
|
removedIds.forEach((id) => removeUsageEntry(draft, id))
|
||||||
})
|
})
|
||||||
@@ -948,6 +1031,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setState("questions", "byMessage", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
messageIds.forEach((id) => {
|
||||||
|
if (next[id]) delete next[id]
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
setState("usage", (prev) => {
|
setState("usage", (prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
delete next[sessionId]
|
delete next[sessionId]
|
||||||
@@ -1012,9 +1103,13 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
replaceMessageId,
|
replaceMessageId,
|
||||||
setMessageInfo,
|
setMessageInfo,
|
||||||
getMessageInfo,
|
getMessageInfo,
|
||||||
upsertPermission,
|
upsertPermission,
|
||||||
removePermission,
|
removePermission,
|
||||||
getPermissionState,
|
getPermissionState,
|
||||||
|
upsertQuestion,
|
||||||
|
removeQuestion,
|
||||||
|
getQuestionState,
|
||||||
|
|
||||||
setSessionRevert,
|
setSessionRevert,
|
||||||
getSessionRevert,
|
getSessionRevert,
|
||||||
rebuildUsage,
|
rebuildUsage,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ClientPart } from "../../types/message"
|
import type { ClientPart } from "../../types/message"
|
||||||
import type { PermissionRequestLike } from "../../types/permission"
|
import type { PermissionRequestLike } from "../../types/permission"
|
||||||
|
import type { QuestionRequest } from "../../types/question"
|
||||||
|
|
||||||
export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error"
|
export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error"
|
||||||
export type MessageRole = "user" | "assistant"
|
export type MessageRole = "user" | "assistant"
|
||||||
@@ -59,6 +60,19 @@ export interface InstancePermissionState {
|
|||||||
byMessage: Record<string, Record<string, PermissionEntry>>
|
byMessage: Record<string, Record<string, PermissionEntry>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QuestionEntry {
|
||||||
|
request: QuestionRequest
|
||||||
|
messageId?: string
|
||||||
|
partId?: string
|
||||||
|
enqueuedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceQuestionState {
|
||||||
|
queue: QuestionEntry[]
|
||||||
|
active: QuestionEntry | null
|
||||||
|
byMessage: Record<string, Record<string, QuestionEntry>>
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScrollSnapshot {
|
export interface ScrollSnapshot {
|
||||||
scrollTop: number
|
scrollTop: number
|
||||||
atBottom: boolean
|
atBottom: boolean
|
||||||
@@ -103,6 +117,7 @@ export interface InstanceMessageState {
|
|||||||
pendingParts: Record<string, PendingPartEntry[]>
|
pendingParts: Record<string, PendingPartEntry[]>
|
||||||
sessionRevisions: Record<string, number>
|
sessionRevisions: Record<string, number>
|
||||||
permissions: InstancePermissionState
|
permissions: InstancePermissionState
|
||||||
|
questions: InstanceQuestionState
|
||||||
usage: Record<string, SessionUsageState>
|
usage: Record<string, SessionUsageState>
|
||||||
scrollState: Record<string, ScrollSnapshot>
|
scrollState: Record<string, ScrollSnapshot>
|
||||||
latestTodos: Record<string, LatestTodoSnapshot | undefined>
|
latestTodos: Record<string, LatestTodoSnapshot | undefined>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
|
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
|
||||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||||
import { updateSessionInfo } from "./message-v2/session-info"
|
import { updateSessionInfo } from "./message-v2/session-info"
|
||||||
import { seedSessionMessagesV2, reconcilePendingPermissionsV2 } from "./message-v2/bridge"
|
import { seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2 } from "./message-v2/bridge"
|
||||||
import { messageStoreBus } from "./message-v2/bus"
|
import { messageStoreBus } from "./message-v2/bus"
|
||||||
import { clearCacheForSession } from "../lib/global-cache"
|
import { clearCacheForSession } from "../lib/global-cache"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
@@ -649,7 +649,9 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
|||||||
// Permissions can be hydrated before messages/tool parts exist in the store.
|
// Permissions can be hydrated before messages/tool parts exist in the store.
|
||||||
// After message hydration, try to attach any pending permissions to tool-call part ids.
|
// After message hydration, try to attach any pending permissions to tool-call part ids.
|
||||||
reconcilePendingPermissionsV2(instanceId, sessionId)
|
reconcilePendingPermissionsV2(instanceId, sessionId)
|
||||||
|
reconcilePendingQuestionsV2(instanceId, sessionId)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to load messages:", error)
|
log.error("Failed to load messages:", error)
|
||||||
|
|||||||
@@ -18,8 +18,17 @@ import { getLogger } from "../lib/logger"
|
|||||||
import { requestData } from "../lib/opencode-api"
|
import { requestData } from "../lib/opencode-api"
|
||||||
import { getPermissionId, getPermissionKind, getRequestIdFromPermissionReply } from "../types/permission"
|
import { getPermissionId, getPermissionKind, getRequestIdFromPermissionReply } from "../types/permission"
|
||||||
import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "../types/permission"
|
import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "../types/permission"
|
||||||
|
import { getQuestionId, getRequestIdFromQuestionReply } from "../types/question"
|
||||||
|
import type { QuestionRequest } from "../types/question"
|
||||||
|
import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2"
|
||||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||||
import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances"
|
import {
|
||||||
|
instances,
|
||||||
|
addPermissionToQueue,
|
||||||
|
removePermissionFromQueue,
|
||||||
|
addQuestionToQueue,
|
||||||
|
removeQuestionFromQueue,
|
||||||
|
} from "./instances"
|
||||||
import { showAlertDialog } from "./alerts"
|
import { showAlertDialog } from "./alerts"
|
||||||
import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
|
import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
|
||||||
import { sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
|
import { sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
|
||||||
@@ -32,9 +41,11 @@ import {
|
|||||||
replaceMessageIdV2,
|
replaceMessageIdV2,
|
||||||
upsertMessageInfoV2,
|
upsertMessageInfoV2,
|
||||||
upsertPermissionV2,
|
upsertPermissionV2,
|
||||||
|
upsertQuestionV2,
|
||||||
removeMessagePartV2,
|
removeMessagePartV2,
|
||||||
removeMessageV2,
|
removeMessageV2,
|
||||||
removePermissionV2,
|
removePermissionV2,
|
||||||
|
removeQuestionV2,
|
||||||
setSessionRevertV2,
|
setSessionRevertV2,
|
||||||
} from "./message-v2/bridge"
|
} from "./message-v2/bridge"
|
||||||
import { messageStoreBus } from "./message-v2/bus"
|
import { messageStoreBus } from "./message-v2/bus"
|
||||||
@@ -102,6 +113,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string): Promise<
|
|||||||
model: existing?.model ?? fetched.model,
|
model: existing?.model ?? fetched.model,
|
||||||
status: existing?.status === "compacting" ? "compacting" : fetched.status,
|
status: existing?.status === "compacting" ? "compacting" : fetched.status,
|
||||||
pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission,
|
pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission,
|
||||||
|
pendingQuestion: existing?.pendingQuestion ?? false,
|
||||||
}
|
}
|
||||||
instanceSessions.set(sessionId, merged)
|
instanceSessions.set(sessionId, merged)
|
||||||
next.set(instanceId, instanceSessions)
|
next.set(instanceId, instanceSessions)
|
||||||
@@ -469,12 +481,36 @@ function handlePermissionReplied(instanceId: string, event: { type: string; prop
|
|||||||
removePermissionV2(instanceId, requestId)
|
removePermissionV2(instanceId, requestId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleQuestionAsked(instanceId: string, event: { type: string; properties?: QuestionRequest } | any): void {
|
||||||
|
const request = event?.properties as QuestionRequest | undefined
|
||||||
|
if (!request) return
|
||||||
|
|
||||||
|
log.info(`[SSE] Question asked: ${getQuestionId(request)}`)
|
||||||
|
addQuestionToQueue(instanceId, request)
|
||||||
|
upsertQuestionV2(instanceId, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuestionAnswered(
|
||||||
|
instanceId: string,
|
||||||
|
event: { type: string; properties?: EventQuestionReplied["properties"] | EventQuestionRejected["properties"] } | any,
|
||||||
|
): void {
|
||||||
|
const properties = event?.properties as EventQuestionReplied["properties"] | EventQuestionRejected["properties"] | undefined
|
||||||
|
const requestId = getRequestIdFromQuestionReply(properties)
|
||||||
|
if (!requestId) return
|
||||||
|
|
||||||
|
log.info(`[SSE] Question answered: ${requestId}`)
|
||||||
|
removeQuestionFromQueue(instanceId, requestId)
|
||||||
|
removeQuestionV2(instanceId, requestId)
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
handleMessagePartRemoved,
|
handleMessagePartRemoved,
|
||||||
handleMessageRemoved,
|
handleMessageRemoved,
|
||||||
handleMessageUpdate,
|
handleMessageUpdate,
|
||||||
handlePermissionReplied,
|
handlePermissionReplied,
|
||||||
handlePermissionUpdated,
|
handlePermissionUpdated,
|
||||||
|
handleQuestionAsked,
|
||||||
|
handleQuestionAnswered,
|
||||||
handleSessionCompacted,
|
handleSessionCompacted,
|
||||||
handleSessionError,
|
handleSessionError,
|
||||||
handleSessionIdle,
|
handleSessionIdle,
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ type InstanceIndicatorCounts = {
|
|||||||
|
|
||||||
const [instanceIndicatorCounts, setInstanceIndicatorCounts] = createSignal<Map<string, InstanceIndicatorCounts>>(new Map())
|
const [instanceIndicatorCounts, setInstanceIndicatorCounts] = createSignal<Map<string, InstanceIndicatorCounts>>(new Map())
|
||||||
|
|
||||||
function getIndicatorBucket(session: Pick<Session, "status" | "pendingPermission">): InstanceSessionIndicatorStatus | "idle" {
|
function getIndicatorBucket(session: Pick<Session, "status" | "pendingPermission" | "pendingQuestion">): InstanceSessionIndicatorStatus | "idle" {
|
||||||
if (session.pendingPermission) {
|
if (session.pendingPermission || session.pendingQuestion) {
|
||||||
return "permission"
|
return "permission"
|
||||||
}
|
}
|
||||||
const status = session.status ?? "idle"
|
const status = session.status ?? "idle"
|
||||||
@@ -126,7 +126,7 @@ function recomputeIndicatorCounts(instanceId: string, instanceSessions: Map<stri
|
|||||||
let compacting = 0
|
let compacting = 0
|
||||||
|
|
||||||
for (const session of instanceSessions.values()) {
|
for (const session of instanceSessions.values()) {
|
||||||
if (session.pendingPermission) {
|
if (session.pendingPermission || session.pendingQuestion) {
|
||||||
permission += 1
|
permission += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -305,6 +305,13 @@ function setSessionPendingPermission(instanceId: string, sessionId: string, pend
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSessionPendingQuestion(instanceId: string, sessionId: string, pending: boolean): void {
|
||||||
|
withSession(instanceId, sessionId, (session) => {
|
||||||
|
if (session.pendingQuestion === pending) return false
|
||||||
|
session.pendingQuestion = pending
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function setActiveSession(instanceId: string, sessionId: string): void {
|
function setActiveSession(instanceId: string, sessionId: string): void {
|
||||||
setActiveSessionId((prev) => {
|
setActiveSessionId((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
@@ -660,6 +667,7 @@ export {
|
|||||||
pruneDraftPrompts,
|
pruneDraftPrompts,
|
||||||
withSession,
|
withSession,
|
||||||
setSessionPendingPermission,
|
setSessionPendingPermission,
|
||||||
|
setSessionPendingQuestion,
|
||||||
setSessionStatus,
|
setSessionStatus,
|
||||||
setActiveSession,
|
setActiveSession,
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ import {
|
|||||||
handleMessageUpdate,
|
handleMessageUpdate,
|
||||||
handlePermissionReplied,
|
handlePermissionReplied,
|
||||||
handlePermissionUpdated,
|
handlePermissionUpdated,
|
||||||
|
handleQuestionAnswered,
|
||||||
|
handleQuestionAsked,
|
||||||
handleSessionCompacted,
|
handleSessionCompacted,
|
||||||
handleSessionError,
|
handleSessionError,
|
||||||
handleSessionIdle,
|
handleSessionIdle,
|
||||||
@@ -81,6 +83,8 @@ sseManager.onSessionStatus = handleSessionStatus
|
|||||||
sseManager.onTuiToast = handleTuiToast
|
sseManager.onTuiToast = handleTuiToast
|
||||||
sseManager.onPermissionUpdated = handlePermissionUpdated
|
sseManager.onPermissionUpdated = handlePermissionUpdated
|
||||||
sseManager.onPermissionReplied = handlePermissionReplied
|
sseManager.onPermissionReplied = handlePermissionReplied
|
||||||
|
sseManager.onQuestionAsked = handleQuestionAsked
|
||||||
|
sseManager.onQuestionAnswered = handleQuestionAnswered
|
||||||
|
|
||||||
export {
|
export {
|
||||||
abortSession,
|
abortSession,
|
||||||
|
|||||||
34
packages/ui/src/types/question.ts
Normal file
34
packages/ui/src/types/question.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type {
|
||||||
|
QuestionRequest,
|
||||||
|
EventQuestionReplied,
|
||||||
|
EventQuestionRejected,
|
||||||
|
} from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
export type { QuestionRequest }
|
||||||
|
|
||||||
|
export function getQuestionId(question: QuestionRequest | null | undefined): string {
|
||||||
|
return question?.id ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuestionSessionId(question: QuestionRequest | null | undefined): string | undefined {
|
||||||
|
return question?.sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuestionMessageId(question: QuestionRequest | null | undefined): string | undefined {
|
||||||
|
return question?.tool?.messageID
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuestionCallId(question: QuestionRequest | null | undefined): string | undefined {
|
||||||
|
return question?.tool?.callID
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuestionCreatedAt(question: QuestionRequest | null | undefined): number {
|
||||||
|
// v2 schema doesn't include created time; best effort for ordering.
|
||||||
|
return Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequestIdFromQuestionReply(
|
||||||
|
properties: EventQuestionReplied["properties"] | EventQuestionRejected["properties"] | null | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
return properties?.requestID
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ export interface Session
|
|||||||
}
|
}
|
||||||
version: string // Include version from SDK Session
|
version: string // Include version from SDK Session
|
||||||
pendingPermission?: boolean // Indicates if session is waiting on user permission
|
pendingPermission?: boolean // Indicates if session is waiting on user permission
|
||||||
|
pendingQuestion?: boolean // Indicates if session is waiting on user input
|
||||||
status: SessionStatus // Single source of truth for session status
|
status: SessionStatus // Single source of truth for session status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user