From f83b67c4486e3c2756b8059ecd28c2b407a9ab1c Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 8 Sep 2026 11:13:07 +0200 Subject: [PATCH] fix(web): keep Cloud Agent workspace tabs stable during progress Overlay truncated activity progress on the current chat tab without changing tab geometry. Working and attention replace the left chat icon so progress does not resize neighbors or move the tab bar. --- .../CloudAgentWorkspaceTabs.test.ts | 120 +++++++++++++++++- .../CloudAgentWorkspaceTabs.tsx | 52 ++++++-- .../cloud-agent-next/CloudChatPage.tsx | 5 + .../cloud-agent-next/terminal-tabs.test.ts | 47 +++++++ 4 files changed, 212 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts index d65314e596..e29d306976 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts +++ b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from '@jest/globals'; -import React, { createElement, type ComponentProps } from 'react'; +import React, { act, createElement, type ComponentProps } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; import { renderToStaticMarkup } from 'react-dom/server'; +import { createRequire } from 'node:module'; import { Button } from '@/components/ui/button'; import { DropdownMenuItem } from '@/components/ui/dropdown-menu'; import type * as DropdownMenuComponents from '@/components/ui/dropdown-menu'; @@ -116,6 +118,41 @@ function getSessionMenuItemProps(title: string): ComponentProps { window: typeof globalThis; document: Document } }; + const { window, document } = parseHTML('
'); + const previous = { + window: globalThis.window, + document: globalThis.document, + HTMLElement: globalThis.HTMLElement, + Element: globalThis.Element, + Node: globalThis.Node, + ResizeObserver: globalThis.ResizeObserver, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, + }; + class ResizeObserver { + observe() {} + disconnect() {} + } + Object.assign(window.HTMLElement.prototype, { scrollIntoView: () => {}, select: () => {} }); + Object.assign(globalThis, { + window, + document, + HTMLElement: window.HTMLElement, + Element: window.Element, + Node: window.Node, + ResizeObserver, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.getElementById('root'); + if (!container) throw new Error('Missing workspace tabs test root'); + return { container, cleanup: () => Object.assign(globalThis, previous) }; +} + describe('CloudAgentWorkspaceTabs', () => { it('renders complete grouped chat titles and selects only the current session tab', () => { const firstTitle = 'Investigate the complete authentication regression across every provider'; @@ -203,8 +240,85 @@ describe('CloudAgentWorkspaceTabs', () => { currentSessionId: busy.sessionId, }); - expect(findButtonMarkup(html, busy.prompt)).toContain('Busy'); - expect(findButtonMarkup(html, attention.prompt)).toContain('aria-label="Waiting for answer"'); + const busyTab = findButtonMarkup(html, busy.prompt); + const attentionTab = findButtonMarkup(html, attention.prompt); + + expect(busyTab).toContain('Busy'); + expect(busyTab).toContain('flex h-4 w-4 shrink-0 items-center justify-center"> { + const first = makeSession('ses_first', 'Short title'); + const second = makeSession('ses_second', 'Neighboring chat title'); + const progress = + 'Preparing a long environment setup command that must truncate instead of resizing tabs'; + const busyHtml = renderWorkspaceTabs({ + chatSessions: [first, second], + currentSessionId: first.sessionId, + currentChatProgress: { sessionId: first.sessionId, message: progress }, + }); + const idleHtml = renderWorkspaceTabs({ + chatSessions: [first, second], + currentSessionId: first.sessionId, + }); + const busyFirstTab = findButtonMarkup(busyHtml, progress); + const busySecondTab = findButtonMarkup(busyHtml, second.prompt); + const idleFirstTab = findButtonMarkup(idleHtml, first.prompt); + + expect(busyFirstTab).toContain('relative min-w-0 max-w-36'); + expect(busyFirstTab).toContain('block truncate text-transparent'); + expect(busyFirstTab).toContain('absolute inset-0 block truncate'); + expect(busyFirstTab).toContain(progress); + expect(busyFirstTab).toContain('flex h-4 w-4 shrink-0 items-center justify-center'); + expect(busySecondTab).not.toContain(progress); + expect(idleFirstTab).toContain('block truncate'); + expect(idleFirstTab).not.toContain('text-transparent'); + expect(idleFirstTab).not.toContain('absolute inset-0 block truncate'); + expect(idleFirstTab).toContain('flex h-4 w-4 shrink-0 items-center justify-center'); + expect(busyHtml).toContain(`role="status" aria-live="polite" class="sr-only">${progress}`); + }); + + it('suppresses the progress overlay while renaming a chat', () => { + const progress = 'Preparing workspace'; + const dom = installTabsTestDom(); + let root: Root | undefined; + + try { + root = createRoot(dom.container); + act(() => { + root?.render( + createElement(CloudAgentWorkspaceTabs, { + activeTabId: CHAT_TAB_ID, + chatSessions: [makeSession('ses_first', 'First worktree chat')], + currentSessionId: 'ses_first', + currentChatProgress: { sessionId: 'ses_first', message: progress }, + onSelectChat: () => undefined, + onCloseChat: () => undefined, + onRenameChat: async () => undefined, + terminals: [], + terminalStatuses: {}, + canCreateTerminal: false, + onSelectTab: () => undefined, + onCreateTerminal: () => undefined, + onCloseTerminal: () => undefined, + }) + ); + }); + const tab = dom.container.querySelector('[role="tab"]'); + if (!tab) throw new Error('Missing chat tab'); + void act(() => tab.dispatchEvent(new window.Event('dblclick', { bubbles: true }))); + + expect(dom.container.textContent).not.toContain(progress); + expect(dom.container.querySelector('.absolute.inset-0')).toBeNull(); + expect( + dom.container.querySelector('input[aria-label="Rename First worktree chat"]') + ).not.toBeNull(); + } finally { + act(() => root?.unmount()); + dom.cleanup(); + } }); it('keeps the split chat action busy and disables only chat creation while pending', () => { diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.tsx b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.tsx index 229b93701f..d44695a55c 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.tsx @@ -1,7 +1,11 @@ 'use client'; import React, { useEffect, useRef, useState } from 'react'; -import { SessionStatusIndicator } from '@/components/shared/SessionStatusIndicator'; +import { + getSessionActivityIndicatorKind, + SessionStatusIndicator, +} from '@/components/shared/SessionStatusIndicator'; +import { StatusSpinner } from '@/components/shared/StatusSpinner'; import { TimeAgo } from '@/components/shared/TimeAgo'; import { Button } from '@/components/ui/button'; import { @@ -52,6 +56,7 @@ const renameHint = 'Double-click to rename.'; export function CloudAgentWorkspaceTabs({ activeTabId, chatSessions, + currentChatProgress, currentSessionId, worktreeId, openChatSessionIds, @@ -72,6 +77,7 @@ export function CloudAgentWorkspaceTabs({ }: { activeTabId: WorkspaceTabId; chatSessions: StoredSession[]; + currentChatProgress?: { sessionId: string; message: string } | null; currentSessionId: string | null; worktreeId?: string | null; openChatSessionIds?: readonly string[]; @@ -237,6 +243,14 @@ export function CloudAgentWorkspaceTabs({ const isDeleting = deletingSessionIds.includes(session.sessionId); const isEditing = editingSessionId === session.sessionId; const canRename = Boolean(onRenameChat) && !isDeleting; + const progress = + currentChatProgress?.sessionId === session.sessionId ? currentChatProgress : null; + const activityKind = isEditing + ? null + : getSessionActivityIndicatorKind( + session.sessionStatus ?? null, + session.sessionStatusUpdatedAt ?? null + ); return (
- - {session.prompt} +

{session.prompt}

+ {progress &&

{progress.message}

} {canRename &&

{renameHint}

}
+ {progress && !isEditing && ( + + {progress.message} + + )} {!isEditing && session.associatedPr && ( diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx index 376acfb81b..a235d939f4 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -963,6 +963,10 @@ export default function CloudChatPage({ preparationAttempts.some(attempt => attempt.status === 'running') ? null : statusIndicator; + const currentChatProgress = + statusIndicator?.type === 'progress' && isCurrentSession && sessionIdFromParams + ? { sessionId: sessionIdFromParams, message: statusIndicator.message } + : null; const placeholder = isLoading ? 'Loading session…' @@ -1063,6 +1067,7 @@ export default function CloudChatPage({ chat.sessionId)} closedChatSessionIds={closedWorktreeChats.map(chat => chat.sessionId)} currentSessionId={sessionIdFromParams} diff --git a/apps/web/src/components/cloud-agent-next/terminal-tabs.test.ts b/apps/web/src/components/cloud-agent-next/terminal-tabs.test.ts index 103cab422e..b49a2bcea4 100644 --- a/apps/web/src/components/cloud-agent-next/terminal-tabs.test.ts +++ b/apps/web/src/components/cloud-agent-next/terminal-tabs.test.ts @@ -424,6 +424,53 @@ describe('CloudChatPage terminal ownership across navigation', () => { expect(mockClosedPtys).toEqual([]); }); + it('passes current session progress to the matching URL session tab', () => { + mockAtomValues.statusIndicator = { type: 'progress', message: 'Preparing workspace' }; + render(); + + expect(mockTabs.currentChatProgress).toEqual({ + sessionId: 'ses_recent', + message: 'Preparing workspace', + }); + }); + + it('does not pass stale progress while the URL and fetched session differ', () => { + mockSessionId = 'ses_historical'; + mockAtomValues.statusIndicator = { type: 'progress', message: 'Preparing workspace' }; + render(); + + expect(mockTabs.currentChatProgress).toBeNull(); + }); + + it('does not pass progress without a URL session even when fetched session data remains', () => { + mockSessionId = null; + mockAtomValues.statusIndicator = { type: 'progress', message: 'Preparing workspace' }; + render(); + + expect(mockTabs.currentChatProgress).toBeNull(); + }); + + it.each([null, { type: 'error', message: 'Failed to prepare workspace' }])( + 'does not pass a non-progress status indicator: %j', + statusIndicator => { + mockAtomValues.statusIndicator = statusIndicator; + render(); + + expect(mockTabs.currentChatProgress).toBeNull(); + } + ); + + it('passes URL session progress while preparation suppresses the transcript status row', () => { + mockAtomValues.statusIndicator = { type: 'progress', message: 'Installing dependencies' }; + mockAtomValues.preparationAttempts = [{ status: 'running', steps: [] }]; + render(); + + expect(mockTabs.currentChatProgress).toEqual({ + sessionId: 'ses_recent', + message: 'Installing dependencies', + }); + }); + it('scopes changes to each sibling control session without replacing its worktree terminal', () => { render(); const terminal = openTerminal();