From f51e06e8f3b27b3fdf9b1130ffdc2762cb73bb9f Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Mon, 29 Jun 2026 12:40:32 +0900 Subject: [PATCH] fix(web): stop pinning resolved permission cards to the bottom of the chat (#974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/src/chat/reducer.test.ts | 40 +++++++++++++++++++++++++++++++++++- web/src/chat/reducer.ts | 31 ++++++++++++---------------- 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts index 4ad3aec1..6000d70c 100644 --- a/web/src/chat/reducer.test.ts +++ b/web/src/chat/reducer.test.ts @@ -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') + }) }) diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 5cb627c1..dca7b9ba 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -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)