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();