From 4201130787f78514ff45de5b998fafc342a06d6f Mon Sep 17 00:00:00 2001
From: Matt Rubens <2600+mrubens@users.noreply.github.com>
Date: Wed, 2 Sep 2026 01:37:58 -0400
Subject: [PATCH] [Fix] Keep the Fast transcript's working indicator up until
the turn ends
The Thinking indicator cleared on the first visible non-user message of
any kind, tool events included. Until integrations became on demand the
first event was almost always the acknowledgement, so this went
unnoticed; now a lookup can come first and the transcript went quiet
with nothing said yet, and it always went quiet after any mid-turn tool
call. The session's responding lease already tracks the real turn and
now ends at the closeout, so show a Working indicator whenever it is
active after visible output.
---
.../FastSessionTranscript.client.test.tsx | 79 +++++++++++++++++++
.../[sessionId]/FastSessionTranscript.tsx | 14 ++--
2 files changed, 87 insertions(+), 6 deletions(-)
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx
index 1dadb5d79..7db861d5e 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx
@@ -720,6 +720,85 @@ describe('FastSessionTranscript', () => {
expect(screen.getByText('Follow-up answer')).toBeInTheDocument();
});
+ it('keeps a working indicator up after a tool call until the turn ends', async () => {
+ vi.spyOn(Date, 'now').mockReturnValue(2);
+ replyMutate.mockResolvedValue({ success: true });
+ render(
+ ,
+ );
+ const input = screen.getByPlaceholderText('Message agent');
+ fireEvent.change(input, { target: { value: 'Look it up' } });
+ fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 });
+ expect(await screen.findByText('Thinking')).toBeInTheDocument();
+
+ // The model's first action is a tool call. That is visible output, so
+ // "Thinking" ends, but the turn is still running.
+ act(() => {
+ FakeEventSource.instances[0]!.emit('messages', {
+ conversationResponding: true,
+ messages: [
+ {
+ ...textMessage({
+ id: 'tool-1',
+ role: 'assistant',
+ text: '',
+ ts: Date.now() + 1,
+ }),
+ role: 'tool' as const,
+ eventType: ACP_ENVELOPE_EVENT_TYPES.ToolCall,
+ contentBlocks: [],
+ payload: {
+ toolCallId: 'tool-1',
+ title: 'search_code',
+ kind: 'mcp',
+ status: 'completed',
+ isExecute: false,
+ isRead: false,
+ isMcp: true,
+ isRoomoteNativeTool: false,
+ mcpServerName: 'github',
+ mcpToolName: 'search_code',
+ serverName: 'github',
+ toolName: 'search_code',
+ command: null,
+ rawInput: { arguments: { query: 'fast' } },
+ },
+ },
+ ],
+ });
+ });
+ expect(screen.queryByText('Thinking')).not.toBeInTheDocument();
+ expect(screen.getByText('Working')).toBeInTheDocument();
+
+ act(() => {
+ FakeEventSource.instances[0]!.emit('messages', {
+ conversationResponding: false,
+ messages: [
+ textMessage({
+ id: 'assistant-2',
+ role: 'assistant',
+ text: 'Found it',
+ ts: Date.now() + 2,
+ }),
+ ],
+ });
+ });
+ expect(screen.queryByText('Working')).not.toBeInTheDocument();
+ expect(screen.getByText('Found it')).toBeInTheDocument();
+ });
+
it('clears Thinking when a follow-up send fails', async () => {
replyMutate.mockRejectedValue(new Error('turn is busy'));
render(
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
index 936fa4659..a4a7f9e69 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
@@ -192,12 +192,12 @@ export function pendingResponseReducer(
};
}
-function ThinkingMessage() {
+function ThinkingMessage({ label = 'Thinking' }: { label?: string }) {
return (
- Thinking
+ {label}
@@ -602,10 +602,12 @@ export function FastSessionTranscript({
/>
{pendingResponseState.pendingAfter !== null ? (
- ) : !isSending &&
- conversationResponding !== true &&
- runningTaskCount > 0 &&
- openTasksPanel ? (
+ ) : conversationResponding === true ? (
+ // Visible output (a tool call, an acknowledgement) has arrived
+ // but the turn is still running: the responding lease is the
+ // authority, and it clears the moment the closeout lands.
+
+ ) : !isSending && runningTaskCount > 0 && openTasksPanel ? (