feat(ui): support question tool requests

Add question queue hydration, inline answering UI, and unify pending requests with permissions.
This commit is contained in:
Shantur Rathore
2026-01-10 09:46:23 +00:00
parent 147c9e3e4b
commit 72f420b6f6
22 changed files with 1098 additions and 96 deletions

View File

@@ -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<string | null>(null
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
// Permission queue management per instance
// Interruption queues (permissions + questions) per instance
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequestLike[]>>(new Map())
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
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() {
const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready")
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) {
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<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(
@@ -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,

View File

@@ -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)

View File

@@ -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<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]
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,

View File

@@ -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<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 {
scrollTop: number
atBottom: boolean
@@ -103,6 +117,7 @@ export interface InstanceMessageState {
pendingParts: Record<string, PendingPartEntry[]>
sessionRevisions: Record<string, number>
permissions: InstancePermissionState
questions: InstanceQuestionState
usage: Record<string, SessionUsageState>
scrollState: Record<string, ScrollSnapshot>
latestTodos: Record<string, LatestTodoSnapshot | undefined>

View File

@@ -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)

View File

@@ -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,

View File

@@ -58,8 +58,8 @@ type InstanceIndicatorCounts = {
const [instanceIndicatorCounts, setInstanceIndicatorCounts] = createSignal<Map<string, InstanceIndicatorCounts>>(new Map())
function getIndicatorBucket(session: Pick<Session, "status" | "pendingPermission">): InstanceSessionIndicatorStatus | "idle" {
if (session.pendingPermission) {
function getIndicatorBucket(session: Pick<Session, "status" | "pendingPermission" | "pendingQuestion">): InstanceSessionIndicatorStatus | "idle" {
if (session.pendingPermission || session.pendingQuestion) {
return "permission"
}
const status = session.status ?? "idle"
@@ -126,7 +126,7 @@ function recomputeIndicatorCounts(instanceId: string, instanceSessions: Map<stri
let compacting = 0
for (const session of instanceSessions.values()) {
if (session.pendingPermission) {
if (session.pendingPermission || session.pendingQuestion) {
permission += 1
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 {
setActiveSessionId((prev) => {
const next = new Map(prev)
@@ -660,6 +667,7 @@ export {
pruneDraftPrompts,
withSession,
setSessionPendingPermission,
setSessionPendingQuestion,
setSessionStatus,
setActiveSession,

View File

@@ -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,