fix(ui): surface pending interruptions

Auto-open the permission/question center when an active instance receives pending interruptions so an earlier permission cannot remain hidden while a later question shows Waiting for earlier responses.

Keep question message-store attachments deduplicated when a request moves from a global fallback entry to a resolved tool part, and order active question state by enqueue time to match the permission flow. Queued permission actions are disabled to preserve the same active-interruption ordering as questions.

Validation: npm run typecheck --workspace @codenomad/ui; node --import tsx --test packages/ui/src/stores/message-v2/instance-store.test.ts
This commit is contained in:
Shantur Rathore 2026-05-16 19:14:24 +01:00
parent 681755888f
commit 9e1e16feb2
5 changed files with 69 additions and 11 deletions

View file

@ -340,10 +340,25 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return t("instanceShell.connection.unknown")
}
const hasPendingRequests = createMemo(() => {
const pendingRequestCount = createMemo(() => {
const permissions = getPermissionQueueLength(props.instance.id)
const questions = getQuestionQueueLength(props.instance.id)
return permissions + questions > 0
return permissions + questions
})
const hasPendingRequests = createMemo(() => {
return pendingRequestCount() > 0
})
let previousPendingRequestCount = 0
let previousIsActiveInstance = props.isActiveInstance !== false
createEffect(() => {
const count = pendingRequestCount()
const isActiveInstance = props.isActiveInstance !== false
if (isActiveInstance && count > 0 && (previousPendingRequestCount === 0 || !previousIsActiveInstance)) {
setPermissionModalOpen(true)
}
previousPendingRequestCount = count
previousIsActiveInstance = isActiveInstance
})
const activePromptInputApi = createMemo(() => {

View file

@ -375,7 +375,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting().has(item.id)}
disabled={!isActive() || permissionSubmitting().has(item.id)}
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "once")}
>
{t("permissionApproval.actions.allowOnce")}
@ -383,7 +383,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting().has(item.id)}
disabled={!isActive() || permissionSubmitting().has(item.id)}
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "always")}
>
{t("permissionApproval.actions.alwaysAllow")}
@ -391,7 +391,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting().has(item.id)}
disabled={!isActive() || permissionSubmitting().has(item.id)}
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "reject")}
>
{t("permissionApproval.actions.deny")}

View file

@ -81,7 +81,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
<button
type="button"
class="tool-call-permission-button"
disabled={props.submitting()}
disabled={!props.active() || props.submitting()}
onClick={() => respond("once")}
>
{t("toolCall.permission.actions.allowOnce")}
@ -89,7 +89,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
<button
type="button"
class="tool-call-permission-button"
disabled={props.submitting()}
disabled={!props.active() || props.submitting()}
onClick={() => respond("always")}
>
{t("toolCall.permission.actions.alwaysAllow")}
@ -97,7 +97,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
<button
type="button"
class="tool-call-permission-button"
disabled={props.submitting()}
disabled={!props.active() || props.submitting()}
onClick={() => respond("reject")}
>
{t("toolCall.permission.actions.deny")}

View file

@ -35,3 +35,36 @@ describe("message-v2 permission state", () => {
assert.equal(store.getPermissionState(undefined, "permission-2")?.active, true)
})
})
describe("message-v2 question state", () => {
it("keeps one question attachment when a duplicate moves from global to a tool part", () => {
const store = createInstanceMessageStore("instance-1")
store.upsertQuestion({
request: { id: "question-1", questions: [] } as any,
enqueuedAt: 1_000,
})
store.upsertQuestion({
request: { id: "question-1", questions: [] } as any,
messageId: "message-1",
partId: "part-1",
enqueuedAt: 2_000,
})
assert.equal(store.state.questions.queue.length, 1)
assert.equal(store.getQuestionState(undefined, "question-1"), null)
assert.equal(store.getQuestionState("message-1", "part-1")?.entry.request.id, "question-1")
assert.equal(store.getQuestionState("message-1", "part-1")?.active, true)
})
it("uses enqueue time when recalculating the active question", () => {
const store = createInstanceMessageStore("instance-1")
store.upsertQuestion({ request: { id: "question-2", questions: [] } as any, enqueuedAt: 2_000 })
store.upsertQuestion({ request: { id: "question-1", questions: [] } as any, enqueuedAt: 1_000 })
assert.equal(store.state.questions.active?.request.id, "question-1")
assert.equal(store.getQuestionState(undefined, "question-1")?.active, true)
assert.equal(store.getQuestionState(undefined, "question-2")?.active, false)
})
})

View file

@ -990,6 +990,17 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
setState(
"questions",
produce((draft) => {
Object.keys(draft.byMessage).forEach((existingMessageKey) => {
const partEntries = draft.byMessage[existingMessageKey]
Object.keys(partEntries).forEach((existingPartKey) => {
if (partEntries[existingPartKey].request.id === entry.request.id) {
delete partEntries[existingPartKey]
}
})
if (Object.keys(partEntries).length === 0) {
delete draft.byMessage[existingMessageKey]
}
})
draft.byMessage[messageKey] = draft.byMessage[messageKey] ?? {}
draft.byMessage[messageKey][partKey] = entry
const existingIndex = draft.queue.findIndex((item) => item.request.id === entry.request.id)
@ -998,9 +1009,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
} else {
draft.queue[existingIndex] = entry
}
if (!draft.active || draft.active.request.id === entry.request.id) {
draft.active = entry
}
draft.queue.sort((left, right) => left.enqueuedAt - right.enqueuedAt)
draft.active = draft.queue[0] ?? null
}),
)
}