diff --git a/package-lock.json b/package-lock.json index 3883a12c..bf5e1a50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1096,9 +1096,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.1.tgz", - "integrity": "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz", + "integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==", "license": "MIT" }, "node_modules/@pinojs/redact": { @@ -7469,7 +7469,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.1.1", + "@opencode-ai/sdk": "1.1.11", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index fa8aa1ea..a3a9bcac 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -3,6 +3,6 @@ "version": "0.5.0", "private": true, "dependencies": { - "@opencode-ai/plugin": "1.1.8" + "@opencode-ai/plugin": "1.1.10" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index d18b89e4..fadc82ac 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,7 +12,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.1.1", + "@opencode-ai/sdk": "1.1.11", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index 33970125..66e79be8 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -1,7 +1,15 @@ import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js" import type { PermissionRequestLike } from "../types/permission" import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission" -import { activePermissionId, getPermissionQueue } from "../stores/instances" +import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question" +import { + activeInterruption, + getPermissionQueue, + getQuestionQueue, + getQuestionEnqueuedAtForInstance, + setActivePermissionIdForInstance, + setActiveQuestionIdForInstance, +} from "../stores/instances" import { loadMessages, setActiveSession } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" import ToolCall from "./tool-call" @@ -88,24 +96,72 @@ function resolveToolCallFromPermission( return null } +function resolveToolCallFromQuestion(instanceId: string, request: QuestionRequest): ResolvedToolCall | null { + const sessionId = getQuestionSessionId(request) + const messageId = getQuestionMessageId(request) + if (!sessionId || !messageId) return null + + const store = messageStoreBus.getInstance(instanceId) + if (!store) return null + + const record = store.getMessage(messageId) + if (!record) return null + + const callId = getQuestionCallId(request) + if (!callId) return null + + for (const partId of record.partIds) { + const partRecord = record.parts?.[partId] + const part = partRecord?.data as any + if (!part || part.type !== "tool") continue + const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId ?? undefined + if (partCallId !== callId) continue + + if (typeof part.id !== "string" || part.id.length === 0) continue + return { + messageId, + sessionId, + toolPart: part as ResolvedToolCall["toolPart"], + messageVersion: record.revision, + partVersion: partRecord?.revision ?? 0, + } + } + + return null +} + const PermissionApprovalModal: Component = (props) => { const [loadingSession, setLoadingSession] = createSignal(null) - const queue = createMemo(() => getPermissionQueue(props.instanceId)) - const activePermId = createMemo(() => activePermissionId().get(props.instanceId) ?? null) + const permissionQueue = createMemo(() => getPermissionQueue(props.instanceId)) + const questionQueue = createMemo(() => getQuestionQueue(props.instanceId)) + const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null) - const orderedQueue = createMemo(() => { - const current = queue() - const activeId = activePermId() - if (!activeId) return current - const index = current.findIndex((entry) => entry.id === activeId) - if (index <= 0) return current - const active = current[index] - if (!active) return current - return [active, ...current.slice(0, index), ...current.slice(index + 1)] + type InterruptionItem = + | { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequestLike } + | { kind: "question"; id: string; sessionId: string; createdAt: number; payload: QuestionRequest } + + const orderedQueue = createMemo(() => { + const permissions = permissionQueue().map((permission) => ({ + kind: "permission" as const, + id: permission.id, + sessionId: getPermissionSessionId(permission) || "", + createdAt: (permission as any)?.time?.created ?? Date.now(), + payload: permission, + })) + + const questions = questionQueue().map((question) => ({ + kind: "question" as const, + id: question.id, + sessionId: getQuestionSessionId(question) || "", + createdAt: getQuestionEnqueuedAtForInstance(props.instanceId, question.id), + payload: question, + })) + + return [...permissions, ...questions].sort((a, b) => a.createdAt - b.createdAt) }) - const hasPermissions = createMemo(() => queue().length > 0) + const hasRequests = createMemo(() => orderedQueue().length > 0) const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") { @@ -122,7 +178,7 @@ const PermissionApprovalModal: Component = (props) createEffect(() => { if (!props.isOpen) return - if (queue().length === 0) { + if (orderedQueue().length === 0) { props.onClose() } }) @@ -156,10 +212,10 @@ const PermissionApprovalModal: Component = (props)

- Permissions + Requests

- 0}> - {queue().length} + 0}> + {orderedQueue().length}
- No pending permissions.
}> + No pending requests.}>
- {(permission) => { - const sessionId = getPermissionSessionId(permission) || "" - const isActive = () => permission.id === activePermId() - const resolved = createMemo(() => resolveToolCallFromPermission(props.instanceId, permission)) + {(item) => { + const isActive = () => active()?.kind === item.kind && active()?.id === item.id + const sessionId = () => item.sessionId + + const resolved = createMemo(() => { + if (item.kind === "permission") { + return resolveToolCallFromPermission(props.instanceId, item.payload) + } + return resolveToolCallFromQuestion(props.instanceId, item.payload) + }) const showFallback = () => !resolved() + const kindLabel = () => (item.kind === "permission" ? "Permission" : "Question") + + const primaryTitle = () => { + if (item.kind === "permission") { + return getPermissionDisplayTitle(item.payload) + } + const first = item.payload.questions?.[0]?.question + return typeof first === "string" && first.trim().length > 0 ? first : "Question" + } + + const secondaryTitle = () => { + if (item.kind === "permission") { + return getPermissionKind(item.payload) + } + const count = item.payload.questions?.length ?? 0 + return count === 1 ? "1 question" : `${count} questions` + } + + const handleActivate = () => { + if (item.kind === "permission") { + setActivePermissionIdForInstance(props.instanceId, item.id) + } else { + setActiveQuestionIdForInstance(props.instanceId, item.id) + } + } + return (
- {getPermissionKind(permission)} + {kindLabel()} + {secondaryTitle()} Active @@ -195,7 +285,10 @@ const PermissionApprovalModal: Component = (props) @@ -203,10 +296,13 @@ const PermissionApprovalModal: Component = (props)
@@ -217,7 +313,7 @@ const PermissionApprovalModal: Component = (props) fallback={
- {getPermissionDisplayTitle(permission)} + {primaryTitle()}
Load session for more information.
diff --git a/packages/ui/src/components/permission-notification-banner.tsx b/packages/ui/src/components/permission-notification-banner.tsx index 17e04907..8c6f97ca 100644 --- a/packages/ui/src/components/permission-notification-banner.tsx +++ b/packages/ui/src/components/permission-notification-banner.tsx @@ -1,6 +1,6 @@ import { Show, createMemo, type Component } from "solid-js" import { ShieldAlert } from "lucide-solid" -import { getPermissionQueueLength } from "../stores/instances" +import { getPermissionQueueLength, getQuestionQueueLength } from "../stores/instances" interface PermissionNotificationBannerProps { instanceId: string @@ -8,15 +8,21 @@ interface PermissionNotificationBannerProps { } const PermissionNotificationBanner: Component = (props) => { - const queueLength = createMemo(() => getPermissionQueueLength(props.instanceId)) - const hasPermissions = createMemo(() => queueLength() > 0) + const permissionCount = createMemo(() => getPermissionQueueLength(props.instanceId)) + const questionCount = createMemo(() => getQuestionQueueLength(props.instanceId)) + const queueLength = createMemo(() => permissionCount() + questionCount()) + const hasRequests = createMemo(() => queueLength() > 0) const label = createMemo(() => { - const count = queueLength() - return `${count} permission${count === 1 ? "" : "s"} pending approval` + const total = queueLength() + 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 ( - + +
+ +
+ +
+ ) + }} + + + +
+
+ + +
+ +
+ Enter + Submit + Esc + Dismiss +
+ + +
{questionError()}
+
+
+
+ + +

Waiting for earlier responses.

+
+ + + + ) + } + + 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 || "" onCleanup(() => { @@ -993,6 +1310,7 @@ export default function ToolCall(props: ToolCallProps) { {renderError()} {renderPermissionBlock()} + {renderQuestionBlock()}
diff --git a/packages/ui/src/components/tool-call/renderers/index.ts b/packages/ui/src/components/tool-call/renderers/index.ts index 3bc838ad..c261a1bb 100644 --- a/packages/ui/src/components/tool-call/renderers/index.ts +++ b/packages/ui/src/components/tool-call/renderers/index.ts @@ -9,6 +9,7 @@ import { todoRenderer } from "./todo" import { webfetchRenderer } from "./webfetch" import { writeRenderer } from "./write" import { invalidRenderer } from "./invalid" +import { questionRenderer } from "./question" const TOOL_RENDERERS: ToolRenderer[] = [ bashRenderer, @@ -19,6 +20,7 @@ const TOOL_RENDERERS: ToolRenderer[] = [ webfetchRenderer, todoRenderer, taskRenderer, + questionRenderer, invalidRenderer, ] diff --git a/packages/ui/src/components/tool-call/renderers/question.tsx b/packages/ui/src/components/tool-call/renderers/question.tsx new file mode 100644 index 00000000..dbfd7e54 --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/question.tsx @@ -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 + }, +} diff --git a/packages/ui/src/components/tool-call/utils.ts b/packages/ui/src/components/tool-call/utils.ts index 229bd0f4..ac32ba60 100644 --- a/packages/ui/src/components/tool-call/utils.ts +++ b/packages/ui/src/components/tool-call/utils.ts @@ -45,6 +45,8 @@ export function getToolIcon(tool: string): string { case "todowrite": case "todoread": return "📋" + case "question": + return "❓" case "list": return "📁" case "patch": diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index 847ca660..98d32d81 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -63,6 +63,8 @@ type SSEEvent = | EventSessionIdle | { type: "permission.updated" | "permission.asked"; properties?: any } | { type: "permission.replied"; properties?: any } + | { type: "question.asked"; properties?: any } + | { type: "question.replied" | "question.rejected"; properties?: any } | EventLspUpdated | TuiToastEvent | BackgroundProcessUpdatedEvent @@ -144,6 +146,13 @@ class SSEManager { case "permission.replied": this.onPermissionReplied?.(instanceId, event as any) 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": this.onLspUpdated?.(instanceId, event as EventLspUpdated) break @@ -178,6 +187,8 @@ class SSEManager { onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void onPermissionUpdated?: (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 onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index adaa4a8e..99a55fde 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -3,6 +3,8 @@ import type { Instance, LogEntry } from "../types/instance" import type { LspStatus } from "@opencode-ai/sdk/v2" import type { PermissionReply, PermissionRequestLike } 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 { sdkManager } from "../lib/sdk-manager" import { sseManager } from "../lib/sse-manager" @@ -18,10 +20,10 @@ import { } from "./sessions" import { fetchCommands, clearCommands } from "./commands" import { preferences } from "./preferences" -import { setSessionPendingPermission } from "./session-state" +import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state" import { setHasInstances } from "./ui" 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 { getLogger } from "../lib/logger" import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata" @@ -34,11 +36,30 @@ const [activeInstanceId, setActiveInstanceId] = createSignal(null const [instanceLogs, setInstanceLogs] = createSignal>(new Map()) const [logStreamingState, setLogStreamingState] = createSignal>(new Map()) -// Permission queue management per instance +// Interruption queues (permissions + questions) per instance const [permissionQueues, setPermissionQueues] = createSignal>(new Map()) const [activePermissionId, setActivePermissionId] = createSignal>(new Map()) const permissionSessionCounts = new Map>() +const [questionQueues, setQuestionQueues] = createSignal>(new Map()) +const [activeQuestionId, setActiveQuestionId] = createSignal>(new Map()) +const questionSessionCounts = new Map>() +const questionEnqueuedAt = new Map() + +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>(new Map()) + function syncHasInstancesFlag() { const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready") setHasInstances(readyExists) @@ -156,6 +177,38 @@ async function syncPendingPermissions(instanceId: string): Promise { } } +async function syncPendingQuestions(instanceId: string): Promise { + const instance = instances().get(instanceId) + if (!instance?.client) return + + try { + const remote = await requestData( + 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) { try { await fetchSessions(instanceId) @@ -166,6 +219,7 @@ async function hydrateInstanceData(instanceId: string) { if (!instance?.client) return await fetchCommands(instanceId, instance.client) await syncPendingPermissions(instanceId) + await syncPendingQuestions(instanceId) } catch (error) { log.error("Failed to fetch initial data", error) } @@ -327,6 +381,7 @@ function removeInstance(id: string) { removeLogContainer(id) clearCommands(id) clearPermissionQueue(id) + clearQuestionQueue(id) clearInstanceMetadata(id) if (activeInstanceId() === id) { @@ -429,6 +484,79 @@ function getPermissionQueueLength(instanceId: string): number { 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 { let sessionCounts = permissionSessionCounts.get(instanceId) if (!sessionCounts) { @@ -464,6 +592,41 @@ function clearSessionPendingCounts(instanceId: string): void { 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 { let inserted = false @@ -485,13 +648,7 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL return } - setActivePermissionId((prev) => { - const next = new Map(prev) - if (!next.get(instanceId)) { - next.set(instanceId, permission.id) - } - return next - }) + recomputeActiveInterruption(instanceId) const sessionId = getPermissionSessionId(permission) if (sessionId) { @@ -526,15 +683,7 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo const updatedQueue = getPermissionQueue(instanceId) - setActivePermissionId((prev) => { - 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 - }) + recomputeActiveInterruption(instanceId) const removed = removedPermission if (removed) { @@ -558,16 +707,140 @@ function clearPermissionQueue(instanceId: string): void { return next }) clearSessionPendingCounts(instanceId) + recomputeActiveInterruption(instanceId) } +function addQuestionToQueue(instanceId: string, request: QuestionRequest): void { + let inserted = false - -function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void { - setActivePermissionId((prev) => { + setQuestionQueues((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 }) + + 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 { + 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 { + 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( @@ -655,7 +928,7 @@ export { getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming, - // Permission management + // Permission + question management permissionQueues, activePermissionId, getPermissionQueue, @@ -665,6 +938,18 @@ export { clearPermissionQueue, sendPermissionResponse, setActivePermissionIdForInstance, + questionQueues, + activeQuestionId, + activeInterruption, + getQuestionQueue, + getQuestionQueueLength, + getQuestionEnqueuedAtForInstance, + addQuestionToQueue, + removeQuestionFromQueue, + clearQuestionQueue, + sendQuestionReply, + sendQuestionReject, + setActiveQuestionIdForInstance, disconnectedInstance, acknowledgeDisconnectedInstance, fetchLspStatus, diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index a2a40abf..65e20bd8 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -1,5 +1,7 @@ import type { PermissionRequestLike } 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 { Session } from "../../types/session" 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 { if (!permissionId) return const store = messageStoreBus.getOrCreate(instanceId) diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index dc073db6..0ebc3c8b 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -12,6 +12,7 @@ import type { PartUpdateInput, PendingPartEntry, PermissionEntry, + QuestionEntry, ReplaceMessageIdOptions, ScrollSnapshot, SessionRecord, @@ -40,6 +41,11 @@ function createInitialState(instanceId: string): InstanceMessageState { active: null, byMessage: {}, }, + questions: { + queue: [], + active: null, + byMessage: {}, + }, usage: {}, scrollState: {}, latestTodos: {}, @@ -193,6 +199,9 @@ export interface InstanceMessageStore { upsertPermission: (entry: PermissionEntry) => void removePermission: (permissionId: string) => void 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 getSessionRevert: (sessionId: string) => SessionRecord["revert"] | undefined | null rebuildUsage: (sessionId: string, infos: Iterable) => 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] if (pending) { setState("pendingParts", options.newId, pending) @@ -832,6 +853,60 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt 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) { const session = state.sessions[sessionId] if (!session) return @@ -873,6 +948,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return next }) + setState("questions", "byMessage", (prev) => { + const next = { ...prev } + removedIds.forEach((id) => { + if (next[id]) delete next[id] + }) + return next + }) + withUsageState(sessionId, (draft) => { removedIds.forEach((id) => removeUsageEntry(draft, id)) }) @@ -948,6 +1031,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return next }) + setState("questions", "byMessage", (prev) => { + const next = { ...prev } + messageIds.forEach((id) => { + if (next[id]) delete next[id] + }) + return next + }) + setState("usage", (prev) => { const next = { ...prev } delete next[sessionId] @@ -1012,9 +1103,13 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt replaceMessageId, setMessageInfo, getMessageInfo, - upsertPermission, - removePermission, - getPermissionState, + upsertPermission, + removePermission, + getPermissionState, + upsertQuestion, + removeQuestion, + getQuestionState, + setSessionRevert, getSessionRevert, rebuildUsage, diff --git a/packages/ui/src/stores/message-v2/types.ts b/packages/ui/src/stores/message-v2/types.ts index 7a52a7c6..4d41cabc 100644 --- a/packages/ui/src/stores/message-v2/types.ts +++ b/packages/ui/src/stores/message-v2/types.ts @@ -1,5 +1,6 @@ import type { ClientPart } from "../../types/message" import type { PermissionRequestLike } from "../../types/permission" +import type { QuestionRequest } from "../../types/question" export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error" export type MessageRole = "user" | "assistant" @@ -59,6 +60,19 @@ export interface InstancePermissionState { byMessage: Record> } +export interface QuestionEntry { + request: QuestionRequest + messageId?: string + partId?: string + enqueuedAt: number +} + +export interface InstanceQuestionState { + queue: QuestionEntry[] + active: QuestionEntry | null + byMessage: Record> +} + export interface ScrollSnapshot { scrollTop: number atBottom: boolean @@ -103,6 +117,7 @@ export interface InstanceMessageState { pendingParts: Record sessionRevisions: Record permissions: InstancePermissionState + questions: InstanceQuestionState usage: Record scrollState: Record latestTodos: Record diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 35b9657c..a2c53276 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -27,7 +27,7 @@ import { import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models" import { normalizeMessagePart } from "./message-v2/normalizers" 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 { clearCacheForSession } from "../lib/global-cache" 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. // After message hydration, try to attach any pending permissions to tool-call part ids. reconcilePendingPermissionsV2(instanceId, sessionId) - + reconcilePendingQuestionsV2(instanceId, sessionId) + + } catch (error) { log.error("Failed to load messages:", error) diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 2f62e0bf..ab658e7a 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -18,8 +18,17 @@ import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" import { getPermissionId, getPermissionKind, getRequestIdFromPermissionReply } 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 { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances" +import { + instances, + addPermissionToQueue, + removePermissionFromQueue, + addQuestionToQueue, + removeQuestionFromQueue, +} from "./instances" import { showAlertDialog } from "./alerts" import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session" import { sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" @@ -32,9 +41,11 @@ import { replaceMessageIdV2, upsertMessageInfoV2, upsertPermissionV2, + upsertQuestionV2, removeMessagePartV2, removeMessageV2, removePermissionV2, + removeQuestionV2, setSessionRevertV2, } from "./message-v2/bridge" import { messageStoreBus } from "./message-v2/bus" @@ -102,6 +113,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string): Promise< model: existing?.model ?? fetched.model, status: existing?.status === "compacting" ? "compacting" : fetched.status, pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission, + pendingQuestion: existing?.pendingQuestion ?? false, } instanceSessions.set(sessionId, merged) next.set(instanceId, instanceSessions) @@ -469,12 +481,36 @@ function handlePermissionReplied(instanceId: string, event: { type: string; prop 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 { handleMessagePartRemoved, handleMessageRemoved, handleMessageUpdate, handlePermissionReplied, handlePermissionUpdated, + handleQuestionAsked, + handleQuestionAnswered, handleSessionCompacted, handleSessionError, handleSessionIdle, diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index ce36090d..fbf2a982 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -58,8 +58,8 @@ type InstanceIndicatorCounts = { const [instanceIndicatorCounts, setInstanceIndicatorCounts] = createSignal>(new Map()) -function getIndicatorBucket(session: Pick): InstanceSessionIndicatorStatus | "idle" { - if (session.pendingPermission) { +function getIndicatorBucket(session: Pick): InstanceSessionIndicatorStatus | "idle" { + if (session.pendingPermission || session.pendingQuestion) { return "permission" } const status = session.status ?? "idle" @@ -126,7 +126,7 @@ function recomputeIndicatorCounts(instanceId: string, instanceSessions: Map { + if (session.pendingQuestion === pending) return false + session.pendingQuestion = pending + }) +} + function setActiveSession(instanceId: string, sessionId: string): void { setActiveSessionId((prev) => { const next = new Map(prev) @@ -660,6 +667,7 @@ export { pruneDraftPrompts, withSession, setSessionPendingPermission, + setSessionPendingQuestion, setSessionStatus, setActiveSession, diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index 286f23dc..48f5298c 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -61,6 +61,8 @@ import { handleMessageUpdate, handlePermissionReplied, handlePermissionUpdated, + handleQuestionAnswered, + handleQuestionAsked, handleSessionCompacted, handleSessionError, handleSessionIdle, @@ -81,6 +83,8 @@ sseManager.onSessionStatus = handleSessionStatus sseManager.onTuiToast = handleTuiToast sseManager.onPermissionUpdated = handlePermissionUpdated sseManager.onPermissionReplied = handlePermissionReplied +sseManager.onQuestionAsked = handleQuestionAsked +sseManager.onQuestionAnswered = handleQuestionAnswered export { abortSession, diff --git a/packages/ui/src/types/question.ts b/packages/ui/src/types/question.ts new file mode 100644 index 00000000..02291d5d --- /dev/null +++ b/packages/ui/src/types/question.ts @@ -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 +} diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index 102e2285..6ed17571 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -37,6 +37,7 @@ export interface Session } version: string // Include version from SDK Session 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 }