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 ? (