Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -116,6 +118,41 @@ function getSessionMenuItemProps(title: string): ComponentProps<typeof DropdownM
return props;
}

function installTabsTestDom() {
const requireFromHere = createRequire(__filename);
const { parseHTML } = requireFromHere(
'../../../../../node_modules/.pnpm/linkedom@0.18.12/node_modules/linkedom'
) as { parseHTML: (html: string) => { window: typeof globalThis; document: Document } };
const { window, document } = parseHTML('<html><body><div id="root"></div></body></html>');
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';
Expand Down Expand Up @@ -203,8 +240,85 @@ describe('CloudAgentWorkspaceTabs', () => {
currentSessionId: busy.sessionId,
});

expect(findButtonMarkup(html, busy.prompt)).toContain('<title>Busy</title>');
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('<title>Busy</title>');
expect(busyTab).toContain('flex h-4 w-4 shrink-0 items-center justify-center"><svg');
expect(attentionTab).toContain('aria-label="Waiting for answer"');
expect(attentionTab).toContain('flex h-4 w-4 shrink-0 items-center justify-center"><span');
});

it('overlays current-chat progress without changing a chat tab title anchor or status slot', () => {
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<HTMLButtonElement>('[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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -52,6 +56,7 @@ const renameHint = 'Double-click to rename.';
export function CloudAgentWorkspaceTabs({
activeTabId,
chatSessions,
currentChatProgress,
currentSessionId,
worktreeId,
openChatSessionIds,
Expand All @@ -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[];
Expand Down Expand Up @@ -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 (
<div
Expand Down Expand Up @@ -332,25 +346,45 @@ export function CloudAgentWorkspaceTabs({
}}
>
<TooltipTrigger>
<MessageSquare className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{isEditing ? (
<span className="sr-only">{session.prompt}</span>
) : (
<>
<span className="min-w-0 max-w-36 truncate">{session.prompt}</span>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{activityKind ? (
<SessionStatusIndicator
status={session.sessionStatus ?? null}
statusUpdatedAt={session.sessionStatusUpdatedAt ?? null}
/>
</>
) : progress && !isEditing ? (
<StatusSpinner className="h-4 w-4 shrink-0 text-gray-600" title="Busy" />
) : (
<MessageSquare className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
)}
</span>
{isEditing ? (
<span className="sr-only">{session.prompt}</span>
) : (
<span className="relative min-w-0 max-w-36">
<span className={cn('block truncate', progress && 'text-transparent')}>
{session.prompt}
</span>
{progress && (
<span aria-hidden="true" className="absolute inset-0 block truncate">
{progress.message}
</span>
)}
</span>
)}
</TooltipTrigger>
</TabsTrigger>
<TooltipContent className="max-w-[min(24rem,calc(100vw-2rem))] wrap-anywhere">
{session.prompt}
<p>{session.prompt}</p>
{progress && <p className="mt-1">{progress.message}</p>}
{canRename && <p className="text-muted-foreground mt-1">{renameHint}</p>}
</TooltipContent>
</Tooltip>
{progress && !isEditing && (
<span role="status" aria-live="polite" className="sr-only">
{progress.message}
</span>
)}

{!isEditing && session.associatedPr && (
<span className="shrink-0 px-1 [@media(any-pointer:coarse)]:[&_button]:min-h-11 [@media(any-pointer:coarse)]:[&_button]:min-w-11">
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/components/cloud-agent-next/CloudChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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…'
Expand Down Expand Up @@ -1063,6 +1067,7 @@ export default function CloudChatPage({
<CloudAgentWorkspaceTabs
activeTabId={workspaceTabs.activeTabId}
chatSessions={worktreeChats}
currentChatProgress={currentChatProgress}
openChatSessionIds={openWorktreeChats.map(chat => chat.sessionId)}
closedChatSessionIds={closedWorktreeChats.map(chat => chat.sessionId)}
currentSessionId={sessionIdFromParams}
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/components/cloud-agent-next/terminal-tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down