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

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