fix(web): stop pinning resolved permission cards to the bottom of the chat (#974)

agentState keeps an answered permission request in completedRequests. When its
tool_use message is not in the loaded window, the permission-only synthesis
appended a card to the end of the timeline — and there is no chronological
re-sort, so the card stays pinned above the composer as a stale "answered"
card that never moves to its place in history. With several answered asks this
piles up at the bottom of the chat.

Synthesize a card only for a *pending* request (the case that needs an
answerable card when its message hasn't loaded). A resolved request is history
and renders only via its own message when that message is in the window.
This commit is contained in:
Junmo Kim
2026-06-29 11:40:32 +08:00
committed by GitHub
parent 2ab3b39887
commit f51e06e8f3
2 changed files with 52 additions and 19 deletions
+39 -1
View File
@@ -3,7 +3,7 @@ import { reduceChatBlocks } from './reducer'
import { normalizeDecryptedMessage } from './normalize'
import type { NormalizedMessage } from './types'
import type { DecryptedMessage } from '@/types/api'
import type { ThreadGoal, ThreadGoalStatus } from '@/types/api'
import type { AgentState, ThreadGoal, ThreadGoalStatus } from '@/types/api'
function userMessage(id: string, text: string, createdAt: number): NormalizedMessage {
return {
@@ -279,4 +279,42 @@ describe('reduceChatBlocks', () => {
tokensUsed: 8016
})
})
it('does not pin a resolved request as a bottom card when its message is not in the window', () => {
// agentState keeps completedRequests after an ask is answered. With no
// tool_use message loaded for it, the permission-only synthesis used to
// append an "answered" card at the end of the timeline (no re-sort),
// pinning it above the composer forever.
const messages = [userMessage('u1', 'hello', 1_700_000_000_000)]
const agentState = {
requests: {},
completedRequests: {
'ask-done': {
tool: 'AskUserQuestion',
arguments: { questions: [] },
status: 'approved',
createdAt: 1_700_000_000_500,
completedAt: 1_700_000_000_600
}
}
} as unknown as AgentState
const reduced = reduceChatBlocks(messages, agentState)
expect(reduced.blocks.some(b => b.kind === 'tool-call' && b.id === 'ask-done')).toBe(false)
})
it('still synthesizes a card for a pending request with no message in the window', () => {
const messages = [userMessage('u1', 'hello', 1_700_000_000_000)]
const agentState = {
requests: {
'ask-pending': { tool: 'AskUserQuestion', arguments: { questions: [] }, createdAt: 1_700_000_000_500 }
},
completedRequests: {}
} as unknown as AgentState
const reduced = reduceChatBlocks(messages, agentState)
const block = reduced.blocks.find(b => b.kind === 'tool-call' && b.id === 'ask-pending')
expect(block).toBeDefined()
expect(block?.kind === 'tool-call' ? block.tool.permission?.status : null).toBe('pending')
})
})
+13 -18
View File
@@ -118,14 +118,23 @@ export function reduceChatBlocks(
const rootResult = reduceTimeline(root, reducerContext)
let hasReadyEvent = rootResult.hasReadyEvent
// Only create permission-only tool cards when there is no tool call/result in the transcript.
// Also skip if the permission is older than the oldest message in the current view,
// to avoid mixing old tool cards with newer messages when paginating.
// Synthesize a tool card only for a *pending* permission that has no tool
// call/result in the transcript — so the user can still answer it when its
// tool_use message hasn't loaded. A resolved request (approved/denied/
// canceled) is history: agentState keeps it in completedRequests, but
// synthesizing it here appends a card to the end of the timeline (there is
// no chronological re-sort), pinning a stale "answered" card above the
// composer forever. Resolved requests render only via their own message,
// when it is in the window.
// Also skip if the permission is older than the oldest message in the
// current view, to avoid mixing old tool cards with newer messages when
// paginating.
const oldestMessageTime = normalized.length > 0
? Math.min(...normalized.map(m => m.createdAt))
: null
for (const [id, entry] of permissionsById) {
if (entry.permission.status !== 'pending') continue
if (toolIdsInMessages.has(id)) continue
if (rootResult.toolBlocksById.has(id)) continue
@@ -137,7 +146,7 @@ export function reduceChatBlocks(
continue
}
const block = ensureToolBlock(rootResult.blocks, rootResult.toolBlocksById, id, {
ensureToolBlock(rootResult.blocks, rootResult.toolBlocksById, id, {
createdAt,
localId: null,
name: entry.toolName,
@@ -145,20 +154,6 @@ export function reduceChatBlocks(
description: null,
permission: entry.permission
})
if (entry.permission.status === 'approved') {
block.tool.state = 'completed'
block.tool.completedAt = entry.permission.completedAt ?? createdAt
if (block.tool.result === undefined) {
block.tool.result = 'Approved'
}
} else if (entry.permission.status === 'denied' || entry.permission.status === 'canceled') {
block.tool.state = 'error'
block.tool.completedAt = entry.permission.completedAt ?? createdAt
if (block.tool.result === undefined && entry.permission.reason) {
block.tool.result = { error: entry.permission.reason }
}
}
}
// Calculate latest usage from messages (find the most recent message with usage data)